diff --git a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt index 99a1b54..1446e96 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt @@ -19,8 +19,7 @@ import timber.log.Timber class MainActivity : AppCompatActivity() { private lateinit var binding: MainActivityBinding private val authViewModel by viewModels() - private val authenticationState: AuthenticationState - get() = authViewModel.currentAuthenticationState + private var lastAuthenticationState: AuthenticationState? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -52,31 +51,29 @@ class MainActivity : AppCompatActivity() { private fun listenToAuthStatuses() { Timber.v("listenToAuthStatuses() called") - authViewModel.authenticationState.observe(this, ::onAuthStateUpdate) + authViewModel.authenticationStateLive.observe(this, ::onAuthStateUpdate) } private fun onAuthStateUpdate(authState: AuthenticationState) { Timber.v("onAuthStateUpdate() called with: it = $authState") + lastAuthenticationState = authState invalidateOptionsMenu() } override fun onCreateOptionsMenu(menu: Menu): Boolean { Timber.v("onCreateOptionsMenu() called with: menu = $menu") menuInflater.inflate(R.menu.main_toolbar, menu) - menu.findItem(R.id.logout).isVisible = authenticationState == AUTHORIZED - menu.findItem(R.id.login).isVisible = authenticationState == UNAUTHORIZED + menu.findItem(R.id.logout).isVisible = lastAuthenticationState == AUTHORIZED + menu.findItem(R.id.login).isVisible = lastAuthenticationState == UNAUTHORIZED return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { Timber.v("onOptionsItemSelected() called with: item = $item") val result = when (item.itemId) { - R.id.logout -> { - authViewModel.logout() - true - } - R.id.login -> { - authViewModel.login() + R.id.logout, R.id.login -> { + // When user clicks logout they don't want to be authorized + authViewModel.authRequested = item.itemId == R.id.login true } else -> super.onOptionsItemSelected(item) diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt new file mode 100644 index 0000000..17186e9 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/FragmentExtensions.kt @@ -0,0 +1,36 @@ +package gq.kirmanak.mealient.extensions + +import androidx.activity.OnBackPressedDispatcher +import androidx.activity.addCallback +import androidx.fragment.app.Fragment +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch + +fun Fragment.executeOnceOnBackPressed(action: () -> Unit) { + val onBackPressedDispatcher = requireActivity().onBackPressedDispatcher + lifecycleScope.launch { + onBackPressedDispatcher.backPressedFlow().first() + action() + onBackPressedDispatcher.onBackPressed() // Execute other callbacks now + } +} + +@OptIn(ExperimentalCoroutinesApi::class) +fun OnBackPressedDispatcher.backPressedFlow(): Flow = callbackFlow { + val callback = addCallback { trySend(Unit) } + awaitClose { + callback.isEnabled = false + callback.remove() + } +} + +inline fun Fragment.collectWithViewLifecycle( + flow: Flow, + crossinline collector: suspend (T) -> Unit, +) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt similarity index 84% rename from app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt rename to app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt index 1b608d0..afb1fad 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/ViewExtensions.kt @@ -1,4 +1,4 @@ -package gq.kirmanak.mealient.ui +package gq.kirmanak.mealient.extensions import android.app.Activity import android.os.Build @@ -10,8 +10,6 @@ import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity import androidx.core.widget.doAfterTextChanged import androidx.lifecycle.LifecycleCoroutineScope -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import com.google.android.material.textfield.TextInputLayout import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -26,20 +24,17 @@ import kotlinx.coroutines.flow.first import timber.log.Timber @OptIn(ExperimentalCoroutinesApi::class) -fun SwipeRefreshLayout.refreshesLiveData(): LiveData { - val callbackFlow: Flow = callbackFlow { - val listener = SwipeRefreshLayout.OnRefreshListener { - Timber.v("Refresh requested") - trySend(Unit).logErrors("refreshesFlow") - } - Timber.v("Adding refresh request listener") - setOnRefreshListener(listener) - awaitClose { - Timber.v("Removing refresh request listener") - setOnRefreshListener(null) - } +fun SwipeRefreshLayout.refreshRequestFlow(): Flow = callbackFlow { + Timber.v("refreshRequestFlow() called") + val listener = SwipeRefreshLayout.OnRefreshListener { + Timber.v("refreshRequestFlow: listener called") + trySend(Unit).logErrors("refreshesFlow") + } + setOnRefreshListener(listener) + awaitClose { + Timber.v("Removing refresh request listener") + setOnRefreshListener(null) } - return callbackFlow.asLiveData() } fun Activity.setSystemUiVisibility(isVisible: Boolean) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt index 0f68663..ece0518 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationFragment.kt @@ -5,15 +5,16 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels -import androidx.lifecycle.LiveData import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized +import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding -import gq.kirmanak.mealient.ui.checkIfInputIsEmpty +import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty +import gq.kirmanak.mealient.extensions.executeOnceOnBackPressed +import kotlinx.coroutines.launch import timber.log.Timber @AndroidEntryPoint @@ -21,13 +22,10 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { private val binding by viewBinding(FragmentAuthenticationBinding::bind) private val viewModel by activityViewModels() - private val authStatuses: LiveData - get() = viewModel.authenticationState - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") - authStatuses.observe(this, ::onAuthStatusChange) + executeOnceOnBackPressed { viewModel.authRequested = false } } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -38,13 +36,6 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { getString(R.string.app_name) } - private fun onAuthStatusChange(isAuthenticated: AuthenticationState) { - Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated") - if (isAuthenticated == AuthenticationState.AUTHORIZED) { - findNavController().popBackStack() - } - } - private fun onLoginClicked(): Unit = with(binding) { Timber.v("onLoginClicked() called") @@ -57,14 +48,23 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { } ?: return button.isClickable = false - viewModel.authenticate(email, pass).observe(viewLifecycleOwner) { - Timber.d("onLoginClicked: result $it") - passwordInputLayout.error = when (it.exceptionOrNull()) { - is Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect) - else -> null - } - - button.isClickable = true + viewLifecycleOwner.lifecycleScope.launch { + onAuthenticationResult(viewModel.authenticate(email, pass)) } } + + private fun onAuthenticationResult(result: Result) { + Timber.v("onAuthenticationResult() called with: result = $result") + if (result.isSuccess) { + findNavController().popBackStack() + return + } + + binding.passwordInputLayout.error = when (result.exceptionOrNull()) { + is NetworkError.Unauthorized -> getString(R.string.fragment_authentication_credentials_incorrect) + else -> null + } + + binding.button.isClickable = true + } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt index b9b4a6d..70ec901 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt @@ -5,16 +5,19 @@ import timber.log.Timber enum class AuthenticationState { AUTHORIZED, AUTH_REQUESTED, - UNAUTHORIZED; + UNAUTHORIZED, + UNKNOWN; companion object { fun determineState( isLoginRequested: Boolean, + showLoginButton: Boolean, isAuthorized: Boolean, ): AuthenticationState { - Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, isAuthorized = $isAuthorized") + Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, showLoginButton = $showLoginButton, isAuthorized = $isAuthorized") val result = when { + !showLoginButton -> UNKNOWN isAuthorized -> AUTHORIZED isLoginRequested -> AUTH_REQUESTED else -> UNAUTHORIZED diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt index a5e0592..d17b360 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationViewModel.kt @@ -1,9 +1,13 @@ package gq.kirmanak.mealient.ui.auth -import androidx.lifecycle.* +import androidx.lifecycle.LiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.auth.AuthRepo import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch import timber.log.Timber @@ -14,41 +18,31 @@ class AuthenticationViewModel @Inject constructor( private val authRepo: AuthRepo, ) : ViewModel() { - private val loginRequestsFlow = MutableStateFlow(false) - val authenticationState: LiveData = loginRequestsFlow.combine( - flow = authRepo.isAuthorizedFlow, - transform = AuthenticationState::determineState - ).asLiveData() - val currentAuthenticationState: AuthenticationState - get() = checkNotNull(authenticationState.value) { "Auth state flow mustn't be null" } + private val authRequestsFlow = MutableStateFlow(false) + private val showLoginButtonFlow = MutableStateFlow(false) + private val authenticationStateFlow = combine( + authRequestsFlow, + showLoginButtonFlow, + authRepo.isAuthorizedFlow, + AuthenticationState::determineState + ) + val authenticationStateLive: LiveData + get() = authenticationStateFlow.asLiveData() + var authRequested: Boolean by authRequestsFlow::value + var showLoginButton: Boolean by showLoginButtonFlow::value - fun authenticate(username: String, password: String): LiveData> { - Timber.v("authenticate() called with: username = $username, password = $password") - val result = MutableLiveData>() + init { viewModelScope.launch { - runCatching { - authRepo.authenticate(username, password) - }.onFailure { - Timber.e(it, "authenticate: can't authenticate") - result.value = Result.failure(it) - }.onSuccess { - Timber.d("authenticate: authenticated") - result.value = Result.success(Unit) + authRequestsFlow.collect { isRequested -> + // Clear auth token on logout request + if (!isRequested) authRepo.logout() } } - return result } - fun logout() { - Timber.v("logout() called") - viewModelScope.launch { - loginRequestsFlow.emit(false) - authRepo.logout() - } - } - - fun login() { - Timber.v("login() called") - viewModelScope.launch { loginRequestsFlow.emit(true) } + suspend fun authenticate(username: String, password: String): Result = runCatching { + authRepo.authenticate(username, password) + }.onFailure { + Timber.e(it, "authenticate: can't authenticate") } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt index 1a76b25..dc6ef5a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -11,7 +11,7 @@ import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding -import gq.kirmanak.mealient.ui.checkIfInputIsEmpty +import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty import timber.log.Timber @AndroidEntryPoint diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt index a7b7fb0..64fd1f3 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewHolder.kt @@ -9,7 +9,7 @@ import timber.log.Timber class RecipeViewHolder( private val binding: ViewHolderRecipeBinding, private val recipeViewModel: RecipeViewModel, - private val clickListener: (RecipeSummaryEntity) -> Unit + private val clickListener: (RecipeSummaryEntity) -> Unit, ) : RecyclerView.ViewHolder(binding.root) { private val loadingPlaceholder by lazy { binding.root.resources.getString(R.string.view_holder_recipe_text_placeholder) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt index f24beec..6ff9334 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipeViewModel.kt @@ -1,8 +1,6 @@ package gq.kirmanak.mealient.ui.recipes import android.widget.ImageView -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn @@ -10,34 +8,17 @@ import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.recipes.RecipeImageLoader import gq.kirmanak.mealient.data.recipes.RecipeRepo import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @HiltViewModel class RecipeViewModel @Inject constructor( - private val recipeRepo: RecipeRepo, + recipeRepo: RecipeRepo, private val recipeImageLoader: RecipeImageLoader ) : ViewModel() { - private var _isRefreshing = MutableLiveData() - val isRefreshing: LiveData get() = _isRefreshing - private val _nextRecipeInfoChannel = Channel() - val nextRecipeInfo: Flow = - _nextRecipeInfoChannel.receiveAsFlow() - - val adapter = RecipesPagingAdapter(this) { - Timber.d("onClick: recipe clicked $it") - viewModelScope.launch { _nextRecipeInfoChannel.send(it) } - } - - init { - setupAdapter() - } + val pagingData = recipeRepo.createPager().flow.cachedIn(viewModelScope) fun loadRecipeImage(view: ImageView, recipeSummary: RecipeSummaryEntity?) { Timber.v("loadRecipeImage() called with: view = $view, recipeSummary = $recipeSummary") @@ -45,21 +26,4 @@ class RecipeViewModel @Inject constructor( recipeImageLoader.loadRecipeImage(view, recipeSummary?.slug) } } - - private fun setupAdapter() { - with(viewModelScope) { - launch { - recipeRepo.createPager().flow.cachedIn(this).collect { - Timber.d("setupAdapter: received data update") - adapter.submitData(it) - } - } - launch { - adapter.onPagesUpdatedFlow.collect { - Timber.d("setupAdapter: pages have been updated") - _isRefreshing.value = false - } - } - } - } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt index cf4b556..dab5abd 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/RecipesFragment.kt @@ -6,17 +6,16 @@ import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import by.kirich1409.viewbindingdelegate.viewBinding import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.databinding.FragmentRecipesBinding +import gq.kirmanak.mealient.extensions.collectWithViewLifecycle +import gq.kirmanak.mealient.extensions.refreshRequestFlow import gq.kirmanak.mealient.ui.auth.AuthenticationState import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel -import gq.kirmanak.mealient.ui.refreshesLiveData -import kotlinx.coroutines.flow.collect import timber.log.Timber @AndroidEntryPoint @@ -27,7 +26,8 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - authViewModel.authenticationState.observe(this, ::onAuthStateChange) + Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") + authViewModel.authenticationStateLive.observe(this, ::onAuthStateChange) } private fun onAuthStateChange(authenticationState: AuthenticationState) { @@ -40,6 +40,7 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") + authViewModel.showLoginButton = true setupRecipeAdapter() (requireActivity() as? AppCompatActivity)?.supportActionBar?.title = null } @@ -56,20 +57,19 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { private fun setupRecipeAdapter() { Timber.v("setupRecipeAdapter() called") - binding.recipes.adapter = viewModel.adapter - viewModel.isRefreshing.observe(viewLifecycleOwner) { - Timber.d("setupRecipeAdapter: isRefreshing = $it") - binding.refresher.isRefreshing = it + val adapter = RecipesPagingAdapter(viewModel, ::navigateToRecipeInfo) + binding.recipes.adapter = adapter + collectWithViewLifecycle(viewModel.pagingData) { + Timber.v("setupRecipeAdapter: received data update") + adapter.submitData(lifecycle, it) } - binding.refresher.refreshesLiveData().observe(viewLifecycleOwner) { - Timber.d("setupRecipeAdapter: received refresh request") - viewModel.adapter.refresh() + collectWithViewLifecycle(adapter.onPagesUpdatedFlow) { + Timber.v("setupRecipeAdapter: pages updated") + binding.refresher.isRefreshing = false } - viewLifecycleOwner.lifecycleScope.launchWhenResumed { - viewModel.nextRecipeInfo.collect { - Timber.d("setupRecipeAdapter: navigating to recipe $it") - navigateToRecipeInfo(it) - } + collectWithViewLifecycle(binding.refresher.refreshRequestFlow()) { + Timber.v("setupRecipeAdapter: received refresh request") + adapter.refresh() } } @@ -78,5 +78,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { Timber.v("onDestroyView() called") // Prevent RV leaking through mObservers list in adapter binding.recipes.adapter = null + authViewModel.showLoginButton = false } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashFragment.kt index 32af21d..e52975f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashFragment.kt @@ -9,8 +9,8 @@ import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.ui.setActionBarVisibility -import gq.kirmanak.mealient.ui.setSystemUiVisibility +import gq.kirmanak.mealient.extensions.setActionBarVisibility +import gq.kirmanak.mealient.extensions.setSystemUiVisibility import timber.log.Timber @AndroidEntryPoint