Merge pull request #32 from kirmanak/fixes

Fix several issues
This commit is contained in:
Kirill Kamakin
2022-04-04 21:00:15 +05:00
committed by GitHub
11 changed files with 130 additions and 140 deletions

View File

@@ -19,8 +19,7 @@ import timber.log.Timber
class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding
private val authViewModel by viewModels<AuthenticationViewModel>()
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)

View File

@@ -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<Unit> = callbackFlow {
val callback = addCallback { trySend(Unit) }
awaitClose {
callback.isEnabled = false
callback.remove()
}
}
inline fun <T> Fragment.collectWithViewLifecycle(
flow: Flow<T>,
crossinline collector: suspend (T) -> Unit,
) = viewLifecycleOwner.lifecycleScope.launch { flow.collect(collector) }

View File

@@ -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<Unit> {
val callbackFlow: Flow<Unit> = 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<Unit> = 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) {

View File

@@ -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<AuthenticationViewModel>()
private val authStatuses: LiveData<AuthenticationState>
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<Unit>) {
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
}
}

View File

@@ -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

View File

@@ -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<AuthenticationState> = 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<AuthenticationState>
get() = authenticationStateFlow.asLiveData()
var authRequested: Boolean by authRequestsFlow::value
var showLoginButton: Boolean by showLoginButtonFlow::value
fun authenticate(username: String, password: String): LiveData<Result<Unit>> {
Timber.v("authenticate() called with: username = $username, password = $password")
val result = MutableLiveData<Result<Unit>>()
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<Unit> = runCatching {
authRepo.authenticate(username, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
}
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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<Boolean>()
val isRefreshing: LiveData<Boolean> get() = _isRefreshing
private val _nextRecipeInfoChannel = Channel<RecipeSummaryEntity>()
val nextRecipeInfo: Flow<RecipeSummaryEntity> =
_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
}
}
}
}
}

View File

@@ -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
}
}

View File

@@ -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