Implement login/logout functionality

This commit is contained in:
Kirill Kamakin
2022-04-04 16:42:22 +05:00
parent f44f54522d
commit 468aa8c02a
26 changed files with 248 additions and 184 deletions

View File

@@ -11,23 +11,23 @@ import javax.inject.Inject
@HiltAndroidApp @HiltAndroidApp
class App : Application() { class App : Application() {
// Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382)
@Inject @Inject
lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin> lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin>
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
Timber.v("onCreate() called") Timber.v("onCreate() called")
setupFlipper() setupFlipper()
} }
private fun setupFlipper() { private fun setupFlipper() {
if (FlipperUtils.shouldEnableFlipper(this)) { if (FlipperUtils.shouldEnableFlipper(this)) {
SoLoader.init(this, false) SoLoader.init(this, false)
val flipperClient = AndroidFlipperClient.getInstance(this) val flipperClient = AndroidFlipperClient.getInstance(this)
for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin) for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin)
flipperClient.start() flipperClient.start()
}
} }
}
} }

View File

@@ -9,6 +9,9 @@ import com.google.android.material.shape.CornerFamily
import com.google.android.material.shape.MaterialShapeDrawable import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.databinding.MainActivityBinding import gq.kirmanak.mealient.databinding.MainActivityBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationState.AUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationState.UNAUTHORIZED
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import timber.log.Timber import timber.log.Timber
@@ -16,7 +19,8 @@ import timber.log.Timber
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding private lateinit var binding: MainActivityBinding
private val authViewModel by viewModels<AuthenticationViewModel>() private val authViewModel by viewModels<AuthenticationViewModel>()
private var isAuthenticated = false private val authenticationState: AuthenticationState
get() = authViewModel.currentAuthenticationState
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -48,32 +52,34 @@ class MainActivity : AppCompatActivity() {
private fun listenToAuthStatuses() { private fun listenToAuthStatuses() {
Timber.v("listenToAuthStatuses() called") Timber.v("listenToAuthStatuses() called")
authViewModel.authenticationStatuses().observe(this) { authViewModel.authenticationState.observe(this, ::onAuthStateUpdate)
changeAuthStatus(it)
}
} }
private fun changeAuthStatus(it: Boolean) { private fun onAuthStateUpdate(authState: AuthenticationState) {
Timber.v("changeAuthStatus() called with: it = $it") Timber.v("onAuthStateUpdate() called with: it = $authState")
if (isAuthenticated == it) return
isAuthenticated = it
invalidateOptionsMenu() invalidateOptionsMenu()
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
Timber.v("onCreateOptionsMenu() called with: menu = $menu") Timber.v("onCreateOptionsMenu() called with: menu = $menu")
menuInflater.inflate(R.menu.main_toolbar, menu) menuInflater.inflate(R.menu.main_toolbar, menu)
menu.findItem(R.id.logout).isVisible = isAuthenticated menu.findItem(R.id.logout).isVisible = authenticationState == AUTHORIZED
menu.findItem(R.id.login).isVisible = authenticationState == UNAUTHORIZED
return true return true
} }
override fun onOptionsItemSelected(item: MenuItem): Boolean { override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item") Timber.v("onOptionsItemSelected() called with: item = $item")
val result = if (item.itemId == R.id.logout) { val result = when (item.itemId) {
authViewModel.logout() R.id.logout -> {
true authViewModel.logout()
} else { true
super.onOptionsItemSelected(item) }
R.id.login -> {
authViewModel.login()
true
}
else -> super.onOptionsItemSelected(item)
} }
return result return result
} }

View File

@@ -4,13 +4,13 @@ import kotlinx.coroutines.flow.Flow
interface AuthRepo { interface AuthRepo {
val isAuthorizedFlow: Flow<Boolean>
suspend fun authenticate(username: String, password: String) suspend fun authenticate(username: String, password: String)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
suspend fun requireAuthHeader(): String suspend fun requireAuthHeader(): String
fun authenticationStatuses(): Flow<Boolean>
suspend fun logout() suspend fun logout()
} }

View File

@@ -3,11 +3,12 @@ package gq.kirmanak.mealient.data.auth
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
interface AuthStorage { interface AuthStorage {
val authHeaderFlow: Flow<String?>
suspend fun storeAuthData(authHeader: String) suspend fun storeAuthData(authHeader: String)
suspend fun getAuthHeader(): String? suspend fun getAuthHeader(): String?
fun authHeaderObservable(): Flow<String?>
suspend fun clearAuthData() suspend fun clearAuthData()
} }

View File

@@ -15,6 +15,9 @@ class AuthRepoImpl @Inject constructor(
private val storage: AuthStorage, private val storage: AuthStorage,
) : AuthRepo { ) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = storage.authHeaderFlow.map { it != null }
override suspend fun authenticate(username: String, password: String) { override suspend fun authenticate(username: String, password: String) {
Timber.v("authenticate() called with: username = $username, password = $password") Timber.v("authenticate() called with: username = $username, password = $password")
val accessToken = dataSource.authenticate(username, password) val accessToken = dataSource.authenticate(username, password)
@@ -27,11 +30,6 @@ class AuthRepoImpl @Inject constructor(
override suspend fun requireAuthHeader(): String = override suspend fun requireAuthHeader(): String =
checkNotNull(getAuthHeader()) { "Auth header is null when it was required" } checkNotNull(getAuthHeader()) { "Auth header is null when it was required" }
override fun authenticationStatuses(): Flow<Boolean> {
Timber.v("authenticationStatuses() called")
return storage.authHeaderObservable().map { it != null }
}
override suspend fun logout() { override suspend fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
storage.clearAuthData() storage.clearAuthData()

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.data.auth.impl package gq.kirmanak.mealient.data.auth.impl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.auth.AuthStorage import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
@@ -12,7 +13,10 @@ class AuthStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : AuthStorage { ) : AuthStorage {
private val authHeaderKey by preferencesStorage::authHeaderKey private val authHeaderKey: Preferences.Key<String>
get() = preferencesStorage.authHeaderKey
override val authHeaderFlow: Flow<String?>
get() = preferencesStorage.valueUpdates(authHeaderKey)
override suspend fun storeAuthData(authHeader: String) { override suspend fun storeAuthData(authHeader: String) {
Timber.v("storeAuthData() called with: authHeader = $authHeader") Timber.v("storeAuthData() called with: authHeader = $authHeader")
@@ -26,11 +30,6 @@ class AuthStorageImpl @Inject constructor(
return token return token
} }
override fun authHeaderObservable(): Flow<String?> {
Timber.v("authHeaderObservable() called")
return preferencesStorage.valueUpdates(authHeaderKey)
}
override suspend fun clearAuthData() { override suspend fun clearAuthData() {
Timber.v("clearAuthData() called") Timber.v("clearAuthData() called")
preferencesStorage.removeValues(authHeaderKey) preferencesStorage.removeValues(authHeaderKey)

View File

@@ -1,5 +1,6 @@
package gq.kirmanak.mealient.data.baseurl package gq.kirmanak.mealient.data.baseurl
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -9,7 +10,8 @@ class BaseURLStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : BaseURLStorage { ) : BaseURLStorage {
private val baseUrlKey by preferencesStorage::baseUrlKey private val baseUrlKey: Preferences.Key<String>
get() = preferencesStorage.baseUrlKey
override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey) override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey)

View File

@@ -1,6 +1,11 @@
package gq.kirmanak.mealient.data.disclaimer package gq.kirmanak.mealient.data.disclaimer
import kotlinx.coroutines.flow.Flow
interface DisclaimerStorage { interface DisclaimerStorage {
val isDisclaimerAcceptedFlow: Flow<Boolean>
suspend fun isDisclaimerAccepted(): Boolean suspend fun isDisclaimerAccepted(): Boolean
suspend fun acceptDisclaimer() suspend fun acceptDisclaimer()

View File

@@ -1,6 +1,9 @@
package gq.kirmanak.mealient.data.disclaimer package gq.kirmanak.mealient.data.disclaimer
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.storage.PreferencesStorage import gq.kirmanak.mealient.data.storage.PreferencesStorage
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -10,7 +13,10 @@ class DisclaimerStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage, private val preferencesStorage: PreferencesStorage,
) : DisclaimerStorage { ) : DisclaimerStorage {
private val isDisclaimerAcceptedKey by preferencesStorage::isDisclaimerAcceptedKey private val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
get() = preferencesStorage.isDisclaimerAcceptedKey
override val isDisclaimerAcceptedFlow: Flow<Boolean>
get() = preferencesStorage.valueUpdates(isDisclaimerAcceptedKey).map { it == true }
override suspend fun isDisclaimerAccepted(): Boolean { override suspend fun isDisclaimerAccepted(): Boolean {
Timber.v("isDisclaimerAccepted() called") Timber.v("isDisclaimerAccepted() called")

View File

@@ -4,8 +4,8 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.activityViewModels
import androidx.lifecycle.Observer import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
@@ -19,22 +19,15 @@ import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind) private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by viewModels<AuthenticationViewModel>() private val viewModel by activityViewModels<AuthenticationViewModel>()
private val authStatuses by lazy { viewModel.authenticationStatuses() } private val authStatuses: LiveData<AuthenticationState>
private val authStatusObserver = Observer<Boolean> { onAuthStatusChange(it) } get() = viewModel.authenticationState
private fun onAuthStatusChange(isAuthenticated: Boolean) {
Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
if (isAuthenticated) {
authStatuses.removeObserver(authStatusObserver)
navigateToRecipes()
}
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
authStatuses.observe(this, authStatusObserver) authStatuses.observe(this, ::onAuthStatusChange)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
@@ -45,9 +38,11 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
getString(R.string.app_name) getString(R.string.app_name)
} }
private fun navigateToRecipes() { private fun onAuthStatusChange(isAuthenticated: AuthenticationState) {
Timber.v("navigateToRecipes() called") Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated")
findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment()) if (isAuthenticated == AuthenticationState.AUTHORIZED) {
findNavController().popBackStack()
}
} }
private fun onLoginClicked(): Unit = with(binding) { private fun onLoginClicked(): Unit = with(binding) {

View File

@@ -0,0 +1,26 @@
package gq.kirmanak.mealient.ui.auth
import timber.log.Timber
enum class AuthenticationState {
AUTHORIZED,
AUTH_REQUESTED,
UNAUTHORIZED;
companion object {
fun determineState(
isLoginRequested: Boolean,
isAuthorized: Boolean,
): AuthenticationState {
Timber.v("determineState() called with: isLoginRequested = $isLoginRequested, isAuthorized = $isAuthorized")
val result = when {
isAuthorized -> AUTHORIZED
isLoginRequested -> AUTH_REQUESTED
else -> UNAUTHORIZED
}
Timber.v("determineState() returned: $result")
return result
}
}
}

View File

@@ -3,7 +3,8 @@ package gq.kirmanak.mealient.ui.auth
import androidx.lifecycle.* import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.data.recipes.RecipeRepo import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
@@ -11,9 +12,16 @@ import javax.inject.Inject
@HiltViewModel @HiltViewModel
class AuthenticationViewModel @Inject constructor( class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo, private val authRepo: AuthRepo,
private val recipeRepo: RecipeRepo
) : ViewModel() { ) : 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" }
fun authenticate(username: String, password: String): LiveData<Result<Unit>> { fun authenticate(username: String, password: String): LiveData<Result<Unit>> {
Timber.v("authenticate() called with: username = $username, password = $password") Timber.v("authenticate() called with: username = $username, password = $password")
val result = MutableLiveData<Result<Unit>>() val result = MutableLiveData<Result<Unit>>()
@@ -31,16 +39,16 @@ class AuthenticationViewModel @Inject constructor(
return result return result
} }
fun authenticationStatuses(): LiveData<Boolean> {
Timber.v("authenticationStatuses() called")
return authRepo.authenticationStatuses().asLiveData()
}
fun logout() { fun logout() {
Timber.v("logout() called") Timber.v("logout() called")
viewModelScope.launch { viewModelScope.launch {
loginRequestsFlow.emit(false)
authRepo.logout() authRepo.logout()
recipeRepo.clearLocalData()
} }
} }
fun login() {
Timber.v("login() called")
viewModelScope.launch { loginRequestsFlow.emit(true) }
}
} }

View File

@@ -4,12 +4,14 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import by.kirich1409.viewbindingdelegate.viewBinding import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.ui.checkIfInputIsEmpty
import timber.log.Timber import timber.log.Timber
@AndroidEntryPoint @AndroidEntryPoint
@@ -22,9 +24,15 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState") Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
viewModel.screenState.observe(viewLifecycleOwner, ::updateState) viewModel.screenState.observe(viewLifecycleOwner, ::updateState)
binding.button.setOnClickListener { binding.button.setOnClickListener(::onProceedClick)
viewModel.saveBaseUrl(binding.urlInput.text.toString()) }
}
private fun onProceedClick(view: View) {
Timber.v("onProceedClick() called with: view = $view")
val url = binding.urlInput.checkIfInputIsEmpty(binding.urlInputLayout, lifecycleScope) {
getString(R.string.fragment_baseurl_url_input_empty)
} ?: return
viewModel.saveBaseUrl(url)
} }
private fun updateState(baseURLScreenState: BaseURLScreenState) { private fun updateState(baseURLScreenState: BaseURLScreenState) {

View File

@@ -24,15 +24,20 @@ class BaseURLViewModel @Inject constructor(
private set(value) { private set(value) {
_screenState.value = value _screenState.value = value
} }
val screenState: LiveData<BaseURLScreenState> by ::_screenState val screenState: LiveData<BaseURLScreenState>
get() = _screenState
fun saveBaseUrl(baseURL: String) { fun saveBaseUrl(baseURL: String) {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL") Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
viewModelScope.launch { checkBaseURL(baseURL) } val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL)
viewModelScope.launch { checkBaseURL(url) }
} }
private suspend fun checkBaseURL(baseURL: String) { private suspend fun checkBaseURL(baseURL: String) {
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
val version = try { val version = try {
// If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL) versionDataSource.getVersionInfo(baseURL)
} catch (e: NetworkError) { } catch (e: NetworkError) {
Timber.e(e, "checkBaseURL: can't get version info") Timber.e(e, "checkBaseURL: can't get version info")
@@ -43,4 +48,9 @@ class BaseURLViewModel @Inject constructor(
baseURLStorage.storeBaseURL(baseURL) baseURLStorage.storeBaseURL(baseURL)
currentScreenState = BaseURLScreenState(null, true) currentScreenState = BaseURLScreenState(null, true)
} }
companion object {
private val ALLOWED_PREFIXES = listOf("http://", "https://")
private const val WITH_PREFIX_FORMAT = "https://%s"
}
} }

View File

@@ -20,16 +20,12 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
listenToAcceptStatus() viewModel.isAccepted.observe(this, ::onAcceptStateChange)
} }
private fun listenToAcceptStatus() { private fun onAcceptStateChange(isAccepted: Boolean) {
Timber.v("listenToAcceptStatus() called") Timber.v("onAcceptStateChange() called with: isAccepted = $isAccepted")
viewModel.isAccepted.observe(this) { if (isAccepted) navigateNext()
Timber.d("listenToAcceptStatus: new status = $it")
if (it) navigateNext()
}
viewModel.checkIsAccepted()
} }
private fun navigateNext() { private fun navigateNext() {

View File

@@ -1,10 +1,7 @@
package gq.kirmanak.mealient.ui.disclaimer package gq.kirmanak.mealient.ui.disclaimer
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
import androidx.lifecycle.LiveData import androidx.lifecycle.*
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -21,25 +18,15 @@ import javax.inject.Inject
class DisclaimerViewModel @Inject constructor( class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage private val disclaimerStorage: DisclaimerStorage
) : ViewModel() { ) : ViewModel() {
private val _isAccepted = MutableLiveData(false) val isAccepted: LiveData<Boolean>
val isAccepted: LiveData<Boolean> = _isAccepted get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData()
private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC) private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC)
val okayCountDown: LiveData<Int> = _okayCountDown val okayCountDown: LiveData<Int> = _okayCountDown
fun checkIsAccepted() {
Timber.v("checkIsAccepted() called")
viewModelScope.launch {
_isAccepted.value = disclaimerStorage.isDisclaimerAccepted()
}
}
fun acceptDisclaimer() { fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called") Timber.v("acceptDisclaimer() called")
viewModelScope.launch { viewModelScope.launch { disclaimerStorage.acceptDisclaimer() }
disclaimerStorage.acceptDisclaimer()
_isAccepted.value = true
}
} }
fun startCountDown() { fun startCountDown() {

View File

@@ -4,6 +4,7 @@ import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
@@ -12,6 +13,8 @@ import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.ui.auth.AuthenticationState
import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel
import gq.kirmanak.mealient.ui.refreshesLiveData import gq.kirmanak.mealient.ui.refreshesLiveData
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import timber.log.Timber import timber.log.Timber
@@ -20,6 +23,19 @@ import timber.log.Timber
class RecipesFragment : Fragment(R.layout.fragment_recipes) { class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind) private val binding by viewBinding(FragmentRecipesBinding::bind)
private val viewModel by viewModels<RecipeViewModel>() private val viewModel by viewModels<RecipeViewModel>()
private val authViewModel by activityViewModels<AuthenticationViewModel>()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
authViewModel.authenticationState.observe(this, ::onAuthStateChange)
}
private fun onAuthStateChange(authenticationState: AuthenticationState) {
Timber.v("onAuthStateChange() called with: authenticationState = $authenticationState")
if (authenticationState == AuthenticationState.AUTH_REQUESTED) {
findNavController().navigate(RecipesFragmentDirections.actionRecipesFragmentToAuthenticationFragment())
}
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState) super.onViewCreated(view, savedInstanceState)

View File

@@ -19,58 +19,58 @@ import javax.inject.Inject
@AndroidEntryPoint @AndroidEntryPoint
class RecipeInfoFragment : BottomSheetDialogFragment() { class RecipeInfoFragment : BottomSheetDialogFragment() {
private val binding by viewBinding(FragmentRecipeInfoBinding::bind) private val binding by viewBinding(FragmentRecipeInfoBinding::bind)
private val arguments by navArgs<RecipeInfoFragmentArgs>() private val arguments by navArgs<RecipeInfoFragmentArgs>()
private val viewModel by viewModels<RecipeInfoViewModel>() private val viewModel by viewModels<RecipeInfoViewModel>()
@Inject @Inject
lateinit var ingredientsAdapter: RecipeIngredientsAdapter lateinit var ingredientsAdapter: RecipeIngredientsAdapter
@Inject @Inject
lateinit var instructionsAdapter: RecipeInstructionsAdapter lateinit var instructionsAdapter: RecipeInstructionsAdapter
override fun onCreateView( override fun onCreateView(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
savedInstanceState: Bundle? savedInstanceState: Bundle?
): View { ): View {
Timber.v("onCreateView() called") Timber.v("onCreateView() called")
return FragmentRecipeInfoBinding.inflate(inflater, container, false).root return FragmentRecipeInfoBinding.inflate(inflater, container, false).root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called")
binding.ingredientsList.adapter = ingredientsAdapter
binding.instructionsList.adapter = instructionsAdapter
viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
viewModel.recipeInfo.observe(viewLifecycleOwner) {
Timber.d("onViewCreated: full info $it")
binding.title.text = it.recipeSummaryEntity.name
binding.description.text = it.recipeSummaryEntity.description
} }
viewModel.listsVisibility.observe(viewLifecycleOwner) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
Timber.d("onViewCreated: lists visibility $it") super.onViewCreated(view, savedInstanceState)
binding.ingredientsHolder.isVisible = it.areIngredientsVisible Timber.v("onViewCreated() called")
binding.instructionsGroup.isVisible = it.areInstructionsVisible
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = binding.ingredientsList.adapter = ingredientsAdapter
BottomSheetDialog(requireContext(), R.style.NoShapeBottomSheetDialog) binding.instructionsList.adapter = instructionsAdapter
override fun onDestroyView() { viewModel.loadRecipeImage(binding.image, arguments.recipeSlug)
super.onDestroyView() viewModel.loadRecipeInfo(arguments.recipeId, arguments.recipeSlug)
Timber.v("onDestroyView() called")
// Prevent RV leaking through mObservers list in adapter viewModel.recipeInfo.observe(viewLifecycleOwner) {
with(binding) { Timber.d("onViewCreated: full info $it")
ingredientsList.adapter = null binding.title.text = it.recipeSummaryEntity.name
instructionsList.adapter = null binding.description.text = it.recipeSummaryEntity.description
}
viewModel.listsVisibility.observe(viewLifecycleOwner) {
Timber.d("onViewCreated: lists visibility $it")
binding.ingredientsHolder.isVisible = it.areIngredientsVisible
binding.instructionsGroup.isVisible = it.areInstructionsVisible
}
}
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog =
BottomSheetDialog(requireContext(), R.style.NoShapeBottomSheetDialog)
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
// Prevent RV leaking through mObservers list in adapter
with(binding) {
ingredientsList.adapter = null
instructionsList.adapter = null
}
} }
}
} }

View File

@@ -24,9 +24,11 @@ constructor(
) : ViewModel() { ) : ViewModel() {
private val _recipeInfo = MutableLiveData<FullRecipeInfo>() private val _recipeInfo = MutableLiveData<FullRecipeInfo>()
val recipeInfo: LiveData<FullRecipeInfo> by ::_recipeInfo val recipeInfo: LiveData<FullRecipeInfo>
get() = _recipeInfo
private val _listsVisibility = MutableLiveData(RecipeInfoListsVisibility()) private val _listsVisibility = MutableLiveData(RecipeInfoListsVisibility())
val listsVisibility: LiveData<RecipeInfoListsVisibility> by ::_listsVisibility val listsVisibility: LiveData<RecipeInfoListsVisibility>
get() = _listsVisibility
fun loadRecipeImage(view: ImageView, recipeSlug: String) { fun loadRecipeImage(view: ImageView, recipeSlug: String) {
Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug") Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug")

View File

@@ -5,6 +5,7 @@ import android.view.View
import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatActivity
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import androidx.navigation.NavDirections
import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.findNavController
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R import gq.kirmanak.mealient.R
@@ -19,10 +20,12 @@ class SplashFragment : Fragment(R.layout.fragment_splash) {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
viewModel.nextDestination.observe(this) { viewModel.nextDestination.observe(this, ::onNextDestination)
Timber.d("onCreate: next destination $it") }
findNavController().navigate(it)
} private fun onNextDestination(navDirections: NavDirections) {
Timber.v("onNextDestination() called with: navDirections = $navDirections")
findNavController().navigate(navDirections)
} }
override fun onViewCreated(view: View, savedInstanceState: Bundle?) { override fun onViewCreated(view: View, savedInstanceState: Bundle?) {

View File

@@ -1,11 +1,17 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android" <menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"> xmlns:app="http://schemas.android.com/apk/res-auto">
<item <item
android:id="@+id/logout" android:id="@+id/login"
android:contentDescription="@string/menu_main_toolbar_content_description_logout" android:contentDescription="@string/menu_main_toolbar_content_description_login"
android:title="@string/menu_main_toolbar_logout" android:title="@string/menu_main_toolbar_login"
app:showAsAction="never" /> app:showAsAction="never" />
<item
android:id="@+id/logout"
android:contentDescription="@string/menu_main_toolbar_content_description_logout"
android:title="@string/menu_main_toolbar_logout"
app:showAsAction="never" />
</menu> </menu>

View File

@@ -9,13 +9,7 @@
android:id="@+id/authenticationFragment" android:id="@+id/authenticationFragment"
android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment" android:name="gq.kirmanak.mealient.ui.auth.AuthenticationFragment"
android:label="AuthenticationFragment" android:label="AuthenticationFragment"
tools:layout="@layout/fragment_authentication"> tools:layout="@layout/fragment_authentication" />
<action
android:id="@+id/action_authenticationFragment_to_recipesFragment"
app:destination="@id/recipesFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
</fragment>
<fragment <fragment
android:id="@+id/recipesFragment" android:id="@+id/recipesFragment"
android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment" android:name="gq.kirmanak.mealient.ui.recipes.RecipesFragment"
@@ -23,9 +17,7 @@
tools:layout="@layout/fragment_recipes"> tools:layout="@layout/fragment_recipes">
<action <action
android:id="@+id/action_recipesFragment_to_authenticationFragment" android:id="@+id/action_recipesFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment" app:destination="@id/authenticationFragment" />
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<action <action
android:id="@+id/action_recipesFragment_to_recipeInfoFragment" android:id="@+id/action_recipesFragment_to_recipeInfoFragment"
app:destination="@id/recipeInfoFragment" /> app:destination="@id/recipeInfoFragment" />
@@ -58,11 +50,6 @@
android:name="gq.kirmanak.mealient.ui.splash.SplashFragment" android:name="gq.kirmanak.mealient.ui.splash.SplashFragment"
android:label="fragment_splash" android:label="fragment_splash"
tools:layout="@layout/fragment_splash"> tools:layout="@layout/fragment_splash">
<action
android:id="@+id/action_splashFragment_to_authenticationFragment"
app:destination="@id/authenticationFragment"
app:popUpTo="@id/nav_graph"
app:popUpToInclusive="true" />
<action <action
android:id="@+id/action_splashFragment_to_disclaimerFragment" android:id="@+id/action_splashFragment_to_disclaimerFragment"
app:destination="@id/disclaimerFragment" app:destination="@id/disclaimerFragment"

View File

@@ -14,11 +14,12 @@
<string name="fragment_disclaimer_main_text">Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie.</string> <string name="fragment_disclaimer_main_text">Этот проект разрабатывается независимо от основного проекта Meale. Он не связан с разработчиками Mealie. О любых проблемах следует писать в репозиторий Mealient, НЕ в репозиторий Mealie.</string>
<string name="fragment_authentication_email_input_empty">E-mail не может быть пустым</string> <string name="fragment_authentication_email_input_empty">E-mail не может быть пустым</string>
<string name="fragment_authentication_password_input_empty">Пароль не может быть пустым</string> <string name="fragment_authentication_password_input_empty">Пароль не может быть пустым</string>
<string name="fragment_authentication_url_input_empty">URL не может быть пустым</string> <string name="fragment_baseurl_url_input_empty">URL не может быть пустым</string>
<string name="fragment_authentication_credentials_incorrect">E-mail или пароль не подходит.</string> <string name="fragment_authentication_credentials_incorrect">E-mail или пароль не подходит.</string>
<string name="fragment_base_url_no_connection">Ошибка подключения, проверьте адрес.</string> <string name="fragment_base_url_no_connection">Ошибка подключения, проверьте адрес.</string>
<string name="fragment_base_url_unexpected_response">Неожиданный ответ. Это Mealie?</string> <string name="fragment_base_url_unexpected_response">Неожиданный ответ. Это Mealie?</string>
<string name="fragment_authentication_unknown_error">Что-то пошло не так, попробуйте еще раз.</string> <string name="fragment_authentication_unknown_error">Что-то пошло не так, попробуйте еще раз.</string>
<string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string> <string name="fragment_base_url_malformed_url">Проверьте формат URL: %s</string>
<string name="fragment_base_url_save">Продолжить</string> <string name="fragment_base_url_save">Продолжить</string>
<string name="menu_main_toolbar_login">Войти</string>
</resources> </resources>

View File

@@ -16,7 +16,7 @@
<string name="view_holder_recipe_instructions_step">Step: %d</string> <string name="view_holder_recipe_instructions_step">Step: %d</string>
<string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string> <string name="fragment_authentication_email_input_empty">E-mail can\'t be empty</string>
<string name="fragment_authentication_password_input_empty">Password can\'t be empty</string> <string name="fragment_authentication_password_input_empty">Password can\'t be empty</string>
<string name="fragment_authentication_url_input_empty">URL can\'t be empty</string> <string name="fragment_baseurl_url_input_empty">URL can\'t be empty</string>
<string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string> <string name="fragment_authentication_credentials_incorrect">E-mail or password is incorrect.</string>
<string name="fragment_base_url_no_connection">Can\'t connect, check address.</string> <string name="fragment_base_url_no_connection">Can\'t connect, check address.</string>
<string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string> <string name="fragment_base_url_unexpected_response">Unexpected response. Is it Mealie?</string>
@@ -24,4 +24,6 @@
<string name="fragment_base_url_malformed_url">Check URL format: %s</string> <string name="fragment_base_url_malformed_url">Check URL format: %s</string>
<string name="fragment_base_url_save">Proceed</string> <string name="fragment_base_url_save">Proceed</string>
<string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string> <string name="fragment_base_url_unknown_error" translatable="false">@string/fragment_authentication_unknown_error</string>
<string name="menu_main_toolbar_content_description_login" translatable="false">@string/menu_main_toolbar_login</string>
<string name="menu_main_toolbar_login">Login</string>
</resources> </resources>

View File

@@ -39,14 +39,14 @@ class AuthRepoImplTest : RobolectricTest() {
@Test @Test
fun `when not authenticated then first auth status is false`() = runTest { fun `when not authenticated then first auth status is false`() = runTest {
coEvery { storage.authHeaderObservable() } returns flowOf(null) coEvery { storage.authHeaderFlow } returns flowOf(null)
assertThat(subject.authenticationStatuses().first()).isFalse() assertThat(subject.isAuthorizedFlow.first()).isFalse()
} }
@Test @Test
fun `when authenticated then first auth status is true`() = runTest { fun `when authenticated then first auth status is true`() = runTest {
coEvery { storage.authHeaderObservable() } returns flowOf(TEST_AUTH_HEADER) coEvery { storage.authHeaderFlow } returns flowOf(TEST_AUTH_HEADER)
assertThat(subject.authenticationStatuses().first()).isTrue() assertThat(subject.isAuthorizedFlow.first()).isTrue()
} }
@Test(expected = Unauthorized::class) @Test(expected = Unauthorized::class)

View File

@@ -35,20 +35,20 @@ class AuthStorageImplTest : HiltRobolectricTest() {
@Test @Test
fun `when didn't store auth data then first token is null`() = runTest { fun `when didn't store auth data then first token is null`() = runTest {
assertThat(subject.authHeaderObservable().first()).isNull() assertThat(subject.authHeaderFlow.first()).isNull()
} }
@Test @Test
fun `when stored auth data then first token is correct`() = runTest { fun `when stored auth data then first token is correct`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER) subject.storeAuthData(TEST_AUTH_HEADER)
assertThat(subject.authHeaderObservable().first()).isEqualTo(TEST_AUTH_HEADER) assertThat(subject.authHeaderFlow.first()).isEqualTo(TEST_AUTH_HEADER)
} }
@Test @Test
fun `when clearAuthData then first token is null`() = runTest { fun `when clearAuthData then first token is null`() = runTest {
subject.storeAuthData(TEST_AUTH_HEADER) subject.storeAuthData(TEST_AUTH_HEADER)
subject.clearAuthData() subject.clearAuthData()
assertThat(subject.authHeaderObservable().first()).isNull() assertThat(subject.authHeaderFlow.first()).isNull()
} }
@Test @Test