Extract Base URL from authentication

This commit is contained in:
Kirill Kamakin
2022-04-04 02:40:32 +05:00
parent 617bcc7eae
commit f44f54522d
47 changed files with 760 additions and 316 deletions

View File

@@ -4,13 +4,16 @@ import android.app.Activity
import android.os.Build
import android.view.View
import android.view.WindowInsets
import android.widget.EditText
import android.widget.TextView
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
import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose
@@ -18,6 +21,8 @@ import kotlinx.coroutines.channels.onClosed
import kotlinx.coroutines.channels.onFailure
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import timber.log.Timber
@OptIn(ExperimentalCoroutinesApi::class)
@@ -79,4 +84,29 @@ fun <T> ChannelResult<T>.logErrors(methodName: String): ChannelResult<T> {
onFailure { Timber.e(it, "$methodName: can't send event") }
onClosed { Timber.e(it, "$methodName: flow has been closed") }
return this
}
fun EditText.checkIfInputIsEmpty(
inputLayout: TextInputLayout,
lifecycleCoroutineScope: LifecycleCoroutineScope,
errorText: () -> String
): String? {
Timber.v("checkIfInputIsEmpty() called with: input = $this, inputLayout = $inputLayout, errorText = $errorText")
val text = text?.toString()
Timber.d("Input text is \"$text\"")
if (text.isNullOrEmpty()) {
inputLayout.error = errorText()
lifecycleCoroutineScope.launchWhenResumed {
waitUntilNotEmpty()
inputLayout.error = null
}
return null
}
return text
}
suspend fun EditText.waitUntilNotEmpty() {
Timber.v("waitUntilNotEmpty() called with: input = $this")
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}

View File

@@ -2,7 +2,6 @@ package gq.kirmanak.mealient.ui.auth
import android.os.Bundle
import android.view.View
import android.widget.EditText
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
@@ -10,14 +9,11 @@ import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
import com.google.android.material.textfield.TextInputLayout
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.*
import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.ui.textChangesFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import gq.kirmanak.mealient.ui.checkIfInputIsEmpty
import timber.log.Timber
@AndroidEntryPoint
@@ -57,61 +53,23 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private fun onLoginClicked(): Unit = with(binding) {
Timber.v("onLoginClicked() called")
val email: String = checkIfInputIsEmpty(emailInput, emailInputLayout) {
val email: String = emailInput.checkIfInputIsEmpty(emailInputLayout, lifecycleScope) {
getString(R.string.fragment_authentication_email_input_empty)
} ?: return
val pass: String = checkIfInputIsEmpty(passwordInput, passwordInputLayout) {
val pass: String = passwordInput.checkIfInputIsEmpty(passwordInputLayout, lifecycleScope) {
getString(R.string.fragment_authentication_password_input_empty)
} ?: return
val url: String = checkIfInputIsEmpty(urlInput, urlInputLayout) {
getString(R.string.fragment_authentication_url_input_empty)
} ?: return
button.isClickable = false
viewModel.authenticate(email, pass, url).observe(viewLifecycleOwner) {
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
}
urlInputLayout.error = when (val exception = it.exceptionOrNull()) {
is NoServerConnection -> getString(R.string.fragment_authentication_no_connection)
is NotMealie -> getString(R.string.fragment_authentication_unexpected_response)
is MalformedUrl -> {
val cause = exception.cause?.message ?: exception.message
getString(R.string.fragment_authentication_url_invalid, cause)
}
is Unauthorized, null -> null
else -> getString(R.string.fragment_authentication_unknown_error)
}
button.isClickable = true
}
}
private fun checkIfInputIsEmpty(
input: EditText,
inputLayout: TextInputLayout,
errorText: () -> String
): String? {
Timber.v("checkIfInputIsEmpty() called with: input = $input, inputLayout = $inputLayout, errorText = $errorText")
val text = input.text?.toString()
Timber.d("Input text is \"$text\"")
if (text.isNullOrEmpty()) {
inputLayout.error = errorText()
viewLifecycleOwner.lifecycleScope.launchWhenResumed {
waitUntilNotEmpty(input)
inputLayout.error = null
}
return null
}
return text
}
private suspend fun waitUntilNotEmpty(input: EditText) {
Timber.v("waitUntilNotEmpty() called with: input = $input")
input.textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
}
}

View File

@@ -14,12 +14,12 @@ class AuthenticationViewModel @Inject constructor(
private val recipeRepo: RecipeRepo
) : ViewModel() {
fun authenticate(username: String, password: String, baseUrl: String): LiveData<Result<Unit>> {
Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl")
fun authenticate(username: String, password: String): LiveData<Result<Unit>> {
Timber.v("authenticate() called with: username = $username, password = $password")
val result = MutableLiveData<Result<Unit>>()
viewModelScope.launch {
runCatching {
authRepo.authenticate(username, password, baseUrl)
authRepo.authenticate(username, password)
}.onFailure {
Timber.e(it, "authenticate: can't authenticate")
result.value = Result.failure(it)

View File

@@ -0,0 +1,47 @@
package gq.kirmanak.mealient.ui.baseurl
import android.os.Bundle
import android.view.View
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
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
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import timber.log.Timber
@AndroidEntryPoint
class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
private val binding by viewBinding(FragmentBaseUrlBinding::bind)
private val viewModel by viewModels<BaseURLViewModel>()
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
binding.button.setOnClickListener {
viewModel.saveBaseUrl(binding.urlInput.text.toString())
}
}
private fun updateState(baseURLScreenState: BaseURLScreenState) {
Timber.v("updateState() called with: baseURLScreenState = $baseURLScreenState")
if (baseURLScreenState.navigateNext) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return
}
binding.urlInputLayout.error = when (val exception = baseURLScreenState.error) {
is NetworkError.NoServerConnection -> getString(R.string.fragment_base_url_no_connection)
is NetworkError.NotMealie -> getString(R.string.fragment_base_url_unexpected_response)
is NetworkError.MalformedUrl -> {
val cause = exception.cause?.message ?: exception.message
getString(R.string.fragment_base_url_malformed_url, cause)
}
null -> null
else -> getString(R.string.fragment_base_url_unknown_error)
}
}
}

View File

@@ -0,0 +1,8 @@
package gq.kirmanak.mealient.ui.baseurl
import gq.kirmanak.mealient.data.network.NetworkError
data class BaseURLScreenState(
val error: NetworkError? = null,
val navigateNext: Boolean = false,
)

View File

@@ -0,0 +1,46 @@
package gq.kirmanak.mealient.ui.baseurl
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.data.network.NetworkError
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class BaseURLViewModel @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val versionDataSource: VersionDataSource,
) : ViewModel() {
private val _screenState = MutableLiveData(BaseURLScreenState())
var currentScreenState: BaseURLScreenState
get() = _screenState.value!!
private set(value) {
_screenState.value = value
}
val screenState: LiveData<BaseURLScreenState> by ::_screenState
fun saveBaseUrl(baseURL: String) {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
viewModelScope.launch { checkBaseURL(baseURL) }
}
private suspend fun checkBaseURL(baseURL: String) {
val version = try {
versionDataSource.getVersionInfo(baseURL)
} catch (e: NetworkError) {
Timber.e(e, "checkBaseURL: can't get version info")
currentScreenState = BaseURLScreenState(e, false)
return
}
Timber.d("checkBaseURL: version is $version")
baseURLStorage.storeBaseURL(baseURL)
currentScreenState = BaseURLScreenState(null, true)
}
}

View File

@@ -27,14 +27,14 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
Timber.v("listenToAcceptStatus() called")
viewModel.isAccepted.observe(this) {
Timber.d("listenToAcceptStatus: new status = $it")
if (it) navigateToAuth()
if (it) navigateNext()
}
viewModel.checkIsAccepted()
}
private fun navigateToAuth() {
Timber.v("navigateToAuth() called")
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToAuthenticationFragment())
private fun navigateNext() {
Timber.v("navigateNext() called")
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToBaseURLFragment())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -5,7 +5,6 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.Observer
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding
@@ -13,7 +12,6 @@ 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.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.refreshesLiveData
import kotlinx.coroutines.flow.collect
import timber.log.Timber
@@ -23,22 +21,10 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind)
private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by viewModels<AuthenticationViewModel>()
private val authStatuses by lazy { authViewModel.authenticationStatuses() }
private val authStatusObserver = Observer<Boolean> { onAuthStatusChange(it) }
private fun onAuthStatusChange(isAuthenticated: Boolean) {
Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
if (!isAuthenticated) {
authStatuses.removeObserver(authStatusObserver)
navigateToAuthFragment()
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
setupRecipeAdapter()
authStatuses.observe(viewLifecycleOwner, authStatusObserver)
(requireActivity() as? AppCompatActivity)?.supportActionBar?.title = null
}
@@ -52,11 +38,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
)
}
private fun navigateToAuthFragment() {
Timber.v("navigateToAuthFragment() called")
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called")
binding.recipes.adapter = viewModel.adapter

View File

@@ -6,17 +6,16 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.navigation.NavDirections
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SplashViewModel @Inject constructor(
private val authRepo: AuthRepo,
private val disclaimerStorage: DisclaimerStorage
private val disclaimerStorage: DisclaimerStorage,
private val baseURLStorage: BaseURLStorage,
) : ViewModel() {
private val _nextDestination = MutableLiveData<NavDirections>()
val nextDestination: LiveData<NavDirections> = _nextDestination
@@ -26,8 +25,8 @@ class SplashViewModel @Inject constructor(
delay(1000)
_nextDestination.value = if (!disclaimerStorage.isDisclaimerAccepted())
SplashFragmentDirections.actionSplashFragmentToDisclaimerFragment()
else if (!authRepo.authenticationStatuses().first())
SplashFragmentDirections.actionSplashFragmentToAuthenticationFragment()
else if (baseURLStorage.getBaseURL() == null)
SplashFragmentDirections.actionSplashFragmentToBaseURLFragment()
else
SplashFragmentDirections.actionSplashFragmentToRecipesFragment()
}