diff --git a/app/src/debug/java/gq/kirmanak/mealient/App.kt b/app/src/debug/java/gq/kirmanak/mealient/App.kt index 072de6f..eb4f24f 100644 --- a/app/src/debug/java/gq/kirmanak/mealient/App.kt +++ b/app/src/debug/java/gq/kirmanak/mealient/App.kt @@ -11,23 +11,23 @@ import javax.inject.Inject @HiltAndroidApp class App : Application() { - // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) - @Inject - lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin> + // Use @JvmSuppressWildcards because otherwise dagger can't inject it (https://stackoverflow.com/a/43149382) + @Inject + lateinit var flipperPlugins: Set<@JvmSuppressWildcards FlipperPlugin> - override fun onCreate() { - super.onCreate() - Timber.plant(Timber.DebugTree()) - Timber.v("onCreate() called") - setupFlipper() - } - - private fun setupFlipper() { - if (FlipperUtils.shouldEnableFlipper(this)) { - SoLoader.init(this, false) - val flipperClient = AndroidFlipperClient.getInstance(this) - for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin) - flipperClient.start() + override fun onCreate() { + super.onCreate() + Timber.plant(Timber.DebugTree()) + Timber.v("onCreate() called") + setupFlipper() + } + + private fun setupFlipper() { + if (FlipperUtils.shouldEnableFlipper(this)) { + SoLoader.init(this, false) + val flipperClient = AndroidFlipperClient.getInstance(this) + for (flipperPlugin in flipperPlugins) flipperClient.addPlugin(flipperPlugin) + flipperClient.start() + } } - } } diff --git a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt index 9618238..99a1b54 100644 --- a/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/MainActivity.kt @@ -9,6 +9,9 @@ import com.google.android.material.shape.CornerFamily import com.google.android.material.shape.MaterialShapeDrawable import dagger.hilt.android.AndroidEntryPoint 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 timber.log.Timber @@ -16,7 +19,8 @@ import timber.log.Timber class MainActivity : AppCompatActivity() { private lateinit var binding: MainActivityBinding private val authViewModel by viewModels() - private var isAuthenticated = false + private val authenticationState: AuthenticationState + get() = authViewModel.currentAuthenticationState override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,32 +52,34 @@ class MainActivity : AppCompatActivity() { private fun listenToAuthStatuses() { Timber.v("listenToAuthStatuses() called") - authViewModel.authenticationStatuses().observe(this) { - changeAuthStatus(it) - } + authViewModel.authenticationState.observe(this, ::onAuthStateUpdate) } - private fun changeAuthStatus(it: Boolean) { - Timber.v("changeAuthStatus() called with: it = $it") - if (isAuthenticated == it) return - isAuthenticated = it + private fun onAuthStateUpdate(authState: AuthenticationState) { + Timber.v("onAuthStateUpdate() called with: it = $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 = isAuthenticated + menu.findItem(R.id.logout).isVisible = authenticationState == AUTHORIZED + menu.findItem(R.id.login).isVisible = authenticationState == UNAUTHORIZED return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { Timber.v("onOptionsItemSelected() called with: item = $item") - val result = if (item.itemId == R.id.logout) { - authViewModel.logout() - true - } else { - super.onOptionsItemSelected(item) + val result = when (item.itemId) { + R.id.logout -> { + authViewModel.logout() + true + } + R.id.login -> { + authViewModel.login() + true + } + else -> super.onOptionsItemSelected(item) } return result } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthDataSource.kt index e97ad2a..576ccab 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthDataSource.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthDataSource.kt @@ -2,7 +2,7 @@ package gq.kirmanak.mealient.data.auth interface AuthDataSource { /** - * Tries to acquire authentication token using the provided credentials on specified server. + * Tries to acquire authentication token using the provided credentials */ - suspend fun authenticate(username: String, password: String, baseUrl: String): String + suspend fun authenticate(username: String, password: String): String } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt index 1426dbe..b0589b2 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthRepo.kt @@ -3,17 +3,14 @@ package gq.kirmanak.mealient.data.auth import kotlinx.coroutines.flow.Flow interface AuthRepo { - suspend fun authenticate(username: String, password: String, baseUrl: String) - suspend fun getBaseUrl(): String? + val isAuthorizedFlow: Flow - suspend fun requireBaseUrl(): String + suspend fun authenticate(username: String, password: String) suspend fun getAuthHeader(): String? suspend fun requireAuthHeader(): String - fun authenticationStatuses(): Flow - suspend fun logout() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt index 215a5da..5955e7f 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/AuthStorage.kt @@ -3,13 +3,12 @@ package gq.kirmanak.mealient.data.auth import kotlinx.coroutines.flow.Flow interface AuthStorage { - suspend fun storeAuthData(authHeader: String, baseUrl: String) - suspend fun getBaseUrl(): String? + val authHeaderFlow: Flow + + suspend fun storeAuthData(authHeader: String) suspend fun getAuthHeader(): String? - fun authHeaderObservable(): Flow - suspend fun clearAuthData() } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt index 0d3ddac..b6606f6 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthDataSourceImpl.kt @@ -1,8 +1,8 @@ package gq.kirmanak.mealient.data.auth.impl import gq.kirmanak.mealient.data.auth.AuthDataSource -import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.* import gq.kirmanak.mealient.data.network.ErrorDetail +import gq.kirmanak.mealient.data.network.NetworkError.* import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull import kotlinx.coroutines.CancellationException @@ -20,13 +20,14 @@ class AuthDataSourceImpl @Inject constructor( private val json: Json, ) : AuthDataSource { - override suspend fun authenticate( - username: String, - password: String, - baseUrl: String - ): String { - Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") - val authService = authServiceFactory.provideService(baseUrl) + override suspend fun authenticate(username: String, password: String): String { + Timber.v("authenticate() called with: username = $username, password = $password") + val authService = try { + authServiceFactory.provideService() + } catch (e: Exception) { + Timber.e(e, "authenticate: can't create Retrofit service") + throw MalformedUrl(e) + } val response = sendRequest(authService, username, password) val accessToken = parseToken(response) Timber.v("authenticate() returned: $accessToken") diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt index 89f0bfd..6e26772 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthRepoImpl.kt @@ -1,14 +1,10 @@ package gq.kirmanak.mealient.data.auth.impl -import android.net.Uri -import androidx.annotation.VisibleForTesting import gq.kirmanak.mealient.data.auth.AuthDataSource import gq.kirmanak.mealient.data.auth.AuthRepo import gq.kirmanak.mealient.data.auth.AuthStorage -import gq.kirmanak.mealient.data.auth.impl.AuthenticationError.MalformedUrl import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map -import okhttp3.HttpUrl.Companion.toHttpUrl import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -19,50 +15,26 @@ class AuthRepoImpl @Inject constructor( private val storage: AuthStorage, ) : AuthRepo { - override suspend fun authenticate( - username: String, - password: String, - baseUrl: String - ) { - Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") - val url = parseBaseUrl(baseUrl) - val accessToken = dataSource.authenticate(username, password, url) + override val isAuthorizedFlow: Flow + get() = storage.authHeaderFlow.map { it != null } + + override suspend fun authenticate(username: String, password: String) { + Timber.v("authenticate() called with: username = $username, password = $password") + val accessToken = dataSource.authenticate(username, password) Timber.d("authenticate result is \"$accessToken\"") - storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken), url) + storage.storeAuthData(AUTH_HEADER_FORMAT.format(accessToken)) } - override suspend fun getBaseUrl(): String? = storage.getBaseUrl() - - override suspend fun requireBaseUrl(): String = - checkNotNull(getBaseUrl()) { "Base URL is null when it was required" } - override suspend fun getAuthHeader(): String? = storage.getAuthHeader() override suspend fun requireAuthHeader(): String = checkNotNull(getAuthHeader()) { "Auth header is null when it was required" } - override fun authenticationStatuses(): Flow { - Timber.v("authenticationStatuses() called") - return storage.authHeaderObservable().map { it != null } - } - override suspend fun logout() { Timber.v("logout() called") storage.clearAuthData() } - @VisibleForTesting - fun parseBaseUrl(baseUrl: String): String = try { - val withScheme = Uri.parse(baseUrl).let { - if (it.scheme == null) it.buildUpon().scheme("https").build() - else it - }.toString() - withScheme.toHttpUrl().toString() - } catch (e: Throwable) { - Timber.e(e, "authenticate: can't parse base url $baseUrl") - throw MalformedUrl(e) - } - companion object { private const val AUTH_HEADER_FORMAT = "Bearer %s" } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt index 43d4618..94243d4 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthStorageImpl.kt @@ -1,5 +1,6 @@ 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.storage.PreferencesStorage import kotlinx.coroutines.flow.Flow @@ -12,21 +13,14 @@ class AuthStorageImpl @Inject constructor( private val preferencesStorage: PreferencesStorage, ) : AuthStorage { - private val authHeaderKey by preferencesStorage::authHeaderKey - private val baseUrlKey by preferencesStorage::baseUrlKey + private val authHeaderKey: Preferences.Key + get() = preferencesStorage.authHeaderKey + override val authHeaderFlow: Flow + get() = preferencesStorage.valueUpdates(authHeaderKey) - override suspend fun storeAuthData(authHeader: String, baseUrl: String) { - Timber.v("storeAuthData() called with: authHeader = $authHeader, baseUrl = $baseUrl") - preferencesStorage.storeValues( - Pair(authHeaderKey, authHeader), - Pair(baseUrlKey, baseUrl), - ) - } - - override suspend fun getBaseUrl(): String? { - val baseUrl = preferencesStorage.getValue(baseUrlKey) - Timber.d("getBaseUrl: base url is $baseUrl") - return baseUrl + override suspend fun storeAuthData(authHeader: String) { + Timber.v("storeAuthData() called with: authHeader = $authHeader") + preferencesStorage.storeValues(Pair(authHeaderKey, authHeader)) } override suspend fun getAuthHeader(): String? { @@ -36,13 +30,8 @@ class AuthStorageImpl @Inject constructor( return token } - override fun authHeaderObservable(): Flow { - Timber.v("authHeaderObservable() called") - return preferencesStorage.valueUpdates(authHeaderKey) - } - override suspend fun clearAuthData() { Timber.v("clearAuthData() called") - preferencesStorage.removeValues(authHeaderKey, baseUrlKey) + preferencesStorage.removeValues(authHeaderKey) } } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt b/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt deleted file mode 100644 index 740505a..0000000 --- a/app/src/main/java/gq/kirmanak/mealient/data/auth/impl/AuthenticationError.kt +++ /dev/null @@ -1,8 +0,0 @@ -package gq.kirmanak.mealient.data.auth.impl - -sealed class AuthenticationError(cause: Throwable) : RuntimeException(cause) { - class Unauthorized(cause: Throwable) : AuthenticationError(cause) - class NoServerConnection(cause: Throwable) : AuthenticationError(cause) - class NotMealie(cause: Throwable) : AuthenticationError(cause) - class MalformedUrl(cause: Throwable) : AuthenticationError(cause) -} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorage.kt new file mode 100644 index 0000000..7864ea9 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorage.kt @@ -0,0 +1,10 @@ +package gq.kirmanak.mealient.data.baseurl + +interface BaseURLStorage { + + suspend fun getBaseURL(): String? + + suspend fun requireBaseURL(): String + + suspend fun storeBaseURL(baseURL: String) +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt new file mode 100644 index 0000000..2081bac --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/BaseURLStorageImpl.kt @@ -0,0 +1,25 @@ +package gq.kirmanak.mealient.data.baseurl + +import androidx.datastore.preferences.core.Preferences +import gq.kirmanak.mealient.data.storage.PreferencesStorage +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class BaseURLStorageImpl @Inject constructor( + private val preferencesStorage: PreferencesStorage, +) : BaseURLStorage { + + private val baseUrlKey: Preferences.Key + get() = preferencesStorage.baseUrlKey + + override suspend fun getBaseURL(): String? = preferencesStorage.getValue(baseUrlKey) + + override suspend fun requireBaseURL(): String = checkNotNull(getBaseURL()) { + "Base URL was null when it was required" + } + + override suspend fun storeBaseURL(baseURL: String) { + preferencesStorage.storeValues(Pair(baseUrlKey, baseURL)) + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSource.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSource.kt new file mode 100644 index 0000000..9bee6c2 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSource.kt @@ -0,0 +1,9 @@ +package gq.kirmanak.mealient.data.baseurl + +import gq.kirmanak.mealient.data.network.NetworkError + +interface VersionDataSource { + + @Throws(NetworkError::class) + suspend fun getVersionInfo(baseUrl: String): VersionInfo +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt new file mode 100644 index 0000000..6e25370 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionDataSourceImpl.kt @@ -0,0 +1,39 @@ +package gq.kirmanak.mealient.data.baseurl + +import gq.kirmanak.mealient.data.network.NetworkError +import gq.kirmanak.mealient.data.network.ServiceFactory +import gq.kirmanak.mealient.extensions.versionInfo +import kotlinx.serialization.SerializationException +import retrofit2.HttpException +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VersionDataSourceImpl @Inject constructor( + private val serviceFactory: ServiceFactory, +) : VersionDataSource { + + override suspend fun getVersionInfo(baseUrl: String): VersionInfo { + Timber.v("getVersionInfo() called with: baseUrl = $baseUrl") + + val service = try { + serviceFactory.provideService(baseUrl) + } catch (e: Exception) { + Timber.e(e, "getVersionInfo: can't create service") + throw NetworkError.MalformedUrl(e) + } + + val response = try { + service.getVersion() + } catch (e: Exception) { + Timber.e(e, "getVersionInfo: can't request version") + when (e) { + is HttpException, is SerializationException -> throw NetworkError.NotMealie(e) + else -> throw NetworkError.NoServerConnection(e) + } + } + + return response.versionInfo() + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt new file mode 100644 index 0000000..d706d60 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionInfo.kt @@ -0,0 +1,7 @@ +package gq.kirmanak.mealient.data.baseurl + +data class VersionInfo( + val production: Boolean, + val version: String, + val demoStatus: Boolean, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt new file mode 100644 index 0000000..9529415 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionResponse.kt @@ -0,0 +1,14 @@ +package gq.kirmanak.mealient.data.baseurl + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class VersionResponse( + @SerialName("production") + val production: Boolean, + @SerialName("version") + val version: String, + @SerialName("demoStatus") + val demoStatus: Boolean, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt new file mode 100644 index 0000000..0271550 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/baseurl/VersionService.kt @@ -0,0 +1,8 @@ +package gq.kirmanak.mealient.data.baseurl + +import retrofit2.http.GET + +interface VersionService { + @GET("api/debug/version") + suspend fun getVersion(): VersionResponse +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorage.kt b/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorage.kt index 22ffbb4..b72c110 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorage.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorage.kt @@ -1,6 +1,11 @@ package gq.kirmanak.mealient.data.disclaimer +import kotlinx.coroutines.flow.Flow + interface DisclaimerStorage { + + val isDisclaimerAcceptedFlow: Flow + suspend fun isDisclaimerAccepted(): Boolean suspend fun acceptDisclaimer() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImpl.kt index f7e313b..78fc2a7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/disclaimer/DisclaimerStorageImpl.kt @@ -1,6 +1,9 @@ package gq.kirmanak.mealient.data.disclaimer +import androidx.datastore.preferences.core.Preferences import gq.kirmanak.mealient.data.storage.PreferencesStorage +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton @@ -10,7 +13,10 @@ class DisclaimerStorageImpl @Inject constructor( private val preferencesStorage: PreferencesStorage, ) : DisclaimerStorage { - private val isDisclaimerAcceptedKey by preferencesStorage::isDisclaimerAcceptedKey + private val isDisclaimerAcceptedKey: Preferences.Key + get() = preferencesStorage.isDisclaimerAcceptedKey + override val isDisclaimerAcceptedFlow: Flow + get() = preferencesStorage.valueUpdates(isDisclaimerAcceptedKey).map { it == true } override suspend fun isDisclaimerAccepted(): Boolean { Timber.v("isDisclaimerAccepted() called") diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/NetworkError.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/NetworkError.kt new file mode 100644 index 0000000..05ef278 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/NetworkError.kt @@ -0,0 +1,8 @@ +package gq.kirmanak.mealient.data.network + +sealed class NetworkError(cause: Throwable) : RuntimeException(cause) { + class Unauthorized(cause: Throwable) : NetworkError(cause) + class NoServerConnection(cause: Throwable) : NetworkError(cause) + class NotMealie(cause: Throwable) : NetworkError(cause) + class MalformedUrl(cause: Throwable) : NetworkError(cause) +} diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt index b08ed87..7b99bac 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/RetrofitServiceFactory.kt @@ -1,28 +1,29 @@ package gq.kirmanak.mealient.data.network +import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import timber.log.Timber -inline fun RetrofitBuilder.createServiceFactory() = - RetrofitServiceFactory(T::class.java, this) +inline fun RetrofitBuilder.createServiceFactory(baseURLStorage: BaseURLStorage) = + RetrofitServiceFactory(T::class.java, this, baseURLStorage) class RetrofitServiceFactory( private val serviceClass: Class, private val retrofitBuilder: RetrofitBuilder, + private val baseURLStorage: BaseURLStorage, ) : ServiceFactory { private val cache: MutableMap = mutableMapOf() - @Synchronized - override fun provideService(baseUrl: String): T { + override suspend fun provideService(baseUrl: String?): T { Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}") - val cached = cache[baseUrl] - return if (cached == null) { - Timber.d("provideService: cache is empty, creating new") - val new = retrofitBuilder.buildRetrofit(baseUrl).create(serviceClass) - cache[baseUrl] = new - new - } else { - cached - } + val url = baseUrl ?: baseURLStorage.requireBaseURL() + return synchronized(cache) { cache[url] ?: createService(url, serviceClass) } + } + + private fun createService(url: String, serviceClass: Class): T { + Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}") + val service = retrofitBuilder.buildRetrofit(url).create(serviceClass) + cache[url] = service + return service } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt index 1ae70d8..e46c2a8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/network/ServiceFactory.kt @@ -2,5 +2,5 @@ package gq.kirmanak.mealient.data.network interface ServiceFactory { - fun provideService(baseUrl: String): T + suspend fun provideService(baseUrl: String? = null): T } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/entity/RecipeSummaryEntity.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/entity/RecipeSummaryEntity.kt index 50dc819..287a4af 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/entity/RecipeSummaryEntity.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/db/entity/RecipeSummaryEntity.kt @@ -11,13 +11,13 @@ data class RecipeSummaryEntity( @PrimaryKey @ColumnInfo(name = "remote_id") val remoteId: Long, @ColumnInfo(name = "name") val name: String, @ColumnInfo(name = "slug") val slug: String, - @ColumnInfo(name = "image") val image: String, + @ColumnInfo(name = "image") val image: String?, @ColumnInfo(name = "description") val description: String, @ColumnInfo(name = "rating") val rating: Int?, @ColumnInfo(name = "date_added") val dateAdded: LocalDate, @ColumnInfo(name = "date_updated") val dateUpdated: LocalDateTime ) { override fun toString(): String { - return "RecipeEntity(remoteId=$remoteId, name='$name')" + return "RecipeSummaryEntity(remoteId=$remoteId, name='$name')" } } \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImpl.kt index b83de42..97f31a8 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeImageLoaderImpl.kt @@ -3,7 +3,7 @@ package gq.kirmanak.mealient.data.recipes.impl import android.widget.ImageView import androidx.annotation.VisibleForTesting import gq.kirmanak.mealient.R -import gq.kirmanak.mealient.data.auth.AuthRepo +import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.recipes.RecipeImageLoader import gq.kirmanak.mealient.ui.ImageLoader import okhttp3.HttpUrl.Companion.toHttpUrlOrNull @@ -14,7 +14,7 @@ import javax.inject.Singleton @Singleton class RecipeImageLoaderImpl @Inject constructor( private val imageLoader: ImageLoader, - private val authRepo: AuthRepo + private val baseURLStorage: BaseURLStorage, ): RecipeImageLoader { override suspend fun loadRecipeImage(view: ImageView, slug: String?) { @@ -25,7 +25,7 @@ class RecipeImageLoaderImpl @Inject constructor( @VisibleForTesting suspend fun generateImageUrl(slug: String?): String? { Timber.v("generateImageUrl() called with: slug = $slug") - val result = authRepo.getBaseUrl() + val result = baseURLStorage.getBaseURL() ?.takeIf { it.isNotBlank() } ?.takeUnless { slug.isNullOrBlank() } ?.toHttpUrlOrNull() diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt index a36f7e1..d17bded 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/impl/RecipeRepoImpl.kt @@ -23,7 +23,7 @@ class RecipeRepoImpl @Inject constructor( ) : RecipeRepo { override fun createPager(): Pager { Timber.v("createPager() called") - val pagingConfig = PagingConfig(pageSize = 30, enablePlaceholders = true) + val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true) return Pager( config = pagingConfig, remoteMediator = mediator, diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt index 05cd801..e6b0f0a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/RecipeDataSourceImpl.kt @@ -30,8 +30,8 @@ class RecipeDataSourceImpl @Inject constructor( private suspend fun getRecipeService(): RecipeService { Timber.v("getRecipeService() called") - return recipeServiceFactory.provideService(authRepo.requireBaseUrl()) + return recipeServiceFactory.provideService() } - private suspend fun getToken(): String = authRepo.requireAuthHeader() + private suspend fun getToken(): String? = authRepo.getAuthHeader() } diff --git a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/response/GetRecipeSummaryResponse.kt b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/response/GetRecipeSummaryResponse.kt index 75c33d5..c5349a9 100644 --- a/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/response/GetRecipeSummaryResponse.kt +++ b/app/src/main/java/gq/kirmanak/mealient/data/recipes/network/response/GetRecipeSummaryResponse.kt @@ -10,7 +10,7 @@ data class GetRecipeSummaryResponse( @SerialName("id") val remoteId: Long, @SerialName("name") val name: String, @SerialName("slug") val slug: String, - @SerialName("image") val image: String, + @SerialName("image") val image: String?, @SerialName("description") val description: String = "", @SerialName("recipeCategory") val recipeCategories: List, @SerialName("tags") val tags: List, diff --git a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt index 2ddd619..18e247a 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/AuthModule.kt @@ -12,6 +12,7 @@ import gq.kirmanak.mealient.data.auth.impl.AuthDataSourceImpl import gq.kirmanak.mealient.data.auth.impl.AuthRepoImpl import gq.kirmanak.mealient.data.auth.impl.AuthService import gq.kirmanak.mealient.data.auth.impl.AuthStorageImpl +import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.network.RetrofitBuilder import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory @@ -21,24 +22,25 @@ import javax.inject.Singleton @InstallIn(SingletonComponent::class) interface AuthModule { - companion object { + companion object { - @Provides - @Singleton - fun provideAuthServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory { - return retrofitBuilder.createServiceFactory() + @Provides + @Singleton + fun provideAuthServiceFactory( + retrofitBuilder: RetrofitBuilder, + baseURLStorage: BaseURLStorage, + ): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage) } - } - @Binds - @Singleton - fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource + @Binds + @Singleton + fun bindAuthDataSource(authDataSourceImpl: AuthDataSourceImpl): AuthDataSource - @Binds - @Singleton - fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage + @Binds + @Singleton + fun bindAuthStorage(authStorageImpl: AuthStorageImpl): AuthStorage - @Binds - @Singleton - fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo + @Binds + @Singleton + fun bindAuthRepo(authRepo: AuthRepoImpl): AuthRepo } diff --git a/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt new file mode 100644 index 0000000..0e5820a --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/di/BaseURLModule.kt @@ -0,0 +1,35 @@ +package gq.kirmanak.mealient.di + +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.data.baseurl.* +import gq.kirmanak.mealient.data.network.RetrofitBuilder +import gq.kirmanak.mealient.data.network.ServiceFactory +import gq.kirmanak.mealient.data.network.createServiceFactory +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface BaseURLModule { + + companion object { + + @Provides + @Singleton + fun provideVersionServiceFactory( + retrofitBuilder: RetrofitBuilder, + baseURLStorage: BaseURLStorage, + ): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage) + } + + @Binds + @Singleton + fun bindVersionDataSource(versionDataSourceImpl: VersionDataSourceImpl): VersionDataSource + + @Binds + @Singleton + fun bindBaseUrlStorage(baseURLStorageImpl: BaseURLStorageImpl): BaseURLStorage +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt index 21699a6..790d812 100644 --- a/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt +++ b/app/src/main/java/gq/kirmanak/mealient/di/RecipeModule.kt @@ -6,6 +6,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import gq.kirmanak.mealient.data.baseurl.BaseURLStorage import gq.kirmanak.mealient.data.network.RetrofitBuilder import gq.kirmanak.mealient.data.network.ServiceFactory import gq.kirmanak.mealient.data.network.createServiceFactory @@ -44,9 +45,10 @@ interface RecipeModule { @Provides @Singleton - fun provideRecipeServiceFactory(retrofitBuilder: RetrofitBuilder): ServiceFactory { - return retrofitBuilder.createServiceFactory() - } + fun provideRecipeServiceFactory( + retrofitBuilder: RetrofitBuilder, + baseURLStorage: BaseURLStorage, + ): ServiceFactory = retrofitBuilder.createServiceFactory(baseURLStorage) @Provides @Singleton diff --git a/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt b/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt index 8a0bafa..394ff01 100644 --- a/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt +++ b/app/src/main/java/gq/kirmanak/mealient/extensions/RemoteToLocalMappings.kt @@ -1,5 +1,7 @@ package gq.kirmanak.mealient.extensions +import gq.kirmanak.mealient.data.baseurl.VersionInfo +import gq.kirmanak.mealient.data.baseurl.VersionResponse import gq.kirmanak.mealient.data.recipes.db.entity.RecipeEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeIngredientEntity import gq.kirmanak.mealient.data.recipes.db.entity.RecipeInstructionEntity @@ -42,3 +44,5 @@ fun GetRecipeSummaryResponse.recipeEntity() = RecipeSummaryEntity( dateAdded = dateAdded, dateUpdated = dateUpdated, ) + +fun VersionResponse.versionInfo() = VersionInfo(production, version, demoStatus) \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt b/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt index 035ccc4..1b608d0 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/ViewExtensions.kt @@ -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 ChannelResult.logErrors(methodName: String): ChannelResult { 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") } \ No newline at end of file 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 e95d28a..0f68663 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 @@ -2,43 +2,32 @@ 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 -import androidx.lifecycle.Observer +import androidx.fragment.app.activityViewModels +import androidx.lifecycle.LiveData 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 class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { private val binding by viewBinding(FragmentAuthenticationBinding::bind) - private val viewModel by viewModels() + private val viewModel by activityViewModels() - private val authStatuses by lazy { viewModel.authenticationStatuses() } - private val authStatusObserver = Observer { onAuthStatusChange(it) } - private fun onAuthStatusChange(isAuthenticated: Boolean) { - Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated") - if (isAuthenticated) { - authStatuses.removeObserver(authStatusObserver) - navigateToRecipes() - } - } + 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, authStatusObserver) + authStatuses.observe(this, ::onAuthStatusChange) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { @@ -49,69 +38,33 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) { getString(R.string.app_name) } - private fun navigateToRecipes() { - Timber.v("navigateToRecipes() called") - findNavController().navigate(AuthenticationFragmentDirections.actionAuthenticationFragmentToRecipesFragment()) + 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") - 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") - } } \ 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 new file mode 100644 index 0000000..b9b4a6d --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/auth/AuthenticationState.kt @@ -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 + } + } +} 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 f630531..a5e0592 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 @@ -3,7 +3,8 @@ package gq.kirmanak.mealient.ui.auth import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel 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 timber.log.Timber import javax.inject.Inject @@ -11,15 +12,22 @@ import javax.inject.Inject @HiltViewModel class AuthenticationViewModel @Inject constructor( private val authRepo: AuthRepo, - private val recipeRepo: RecipeRepo ) : ViewModel() { - fun authenticate(username: String, password: String, baseUrl: String): LiveData> { - Timber.v("authenticate() called with: username = $username, password = $password, baseUrl = $baseUrl") + 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" } + + fun authenticate(username: String, password: String): LiveData> { + Timber.v("authenticate() called with: username = $username, password = $password") val result = MutableLiveData>() 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) @@ -31,16 +39,16 @@ class AuthenticationViewModel @Inject constructor( return result } - fun authenticationStatuses(): LiveData { - Timber.v("authenticationStatuses() called") - return authRepo.authenticationStatuses().asLiveData() - } - fun logout() { Timber.v("logout() called") viewModelScope.launch { + loginRequestsFlow.emit(false) authRepo.logout() - recipeRepo.clearLocalData() } } + + fun login() { + Timber.v("login() called") + viewModelScope.launch { loginRequestsFlow.emit(true) } + } } \ 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 new file mode 100644 index 0000000..1a76b25 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLFragment.kt @@ -0,0 +1,55 @@ +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.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 +import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding +import gq.kirmanak.mealient.ui.checkIfInputIsEmpty +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() + + 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(::onProceedClick) + } + + 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) { + 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) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt new file mode 100644 index 0000000..bc6bf65 --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLScreenState.kt @@ -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, +) diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt new file mode 100644 index 0000000..33b932e --- /dev/null +++ b/app/src/main/java/gq/kirmanak/mealient/ui/baseurl/BaseURLViewModel.kt @@ -0,0 +1,56 @@ +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 + get() = _screenState + + fun saveBaseUrl(baseURL: String) { + Timber.v("saveBaseUrl() called with: baseURL = $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) { + Timber.v("checkBaseURL() called with: baseURL = $baseURL") + val version = try { + // If it returns proper version info then it must be a Mealie + 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) + } + + companion object { + private val ALLOWED_PREFIXES = listOf("http://", "https://") + private const val WITH_PREFIX_FORMAT = "https://%s" + } +} \ No newline at end of file diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt index 585e9f8..b9ac72e 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerFragment.kt @@ -20,21 +20,17 @@ class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") - listenToAcceptStatus() + viewModel.isAccepted.observe(this, ::onAcceptStateChange) } - private fun listenToAcceptStatus() { - Timber.v("listenToAcceptStatus() called") - viewModel.isAccepted.observe(this) { - Timber.d("listenToAcceptStatus: new status = $it") - if (it) navigateToAuth() - } - viewModel.checkIsAccepted() + private fun onAcceptStateChange(isAccepted: Boolean) { + Timber.v("onAcceptStateChange() called with: isAccepted = $isAccepted") + if (isAccepted) navigateNext() } - 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?) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt index 9f3535a..0c796d7 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/disclaimer/DisclaimerViewModel.kt @@ -1,10 +1,7 @@ package gq.kirmanak.mealient.ui.disclaimer import androidx.annotation.VisibleForTesting -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope +import androidx.lifecycle.* import dagger.hilt.android.lifecycle.HiltViewModel import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage import kotlinx.coroutines.delay @@ -21,25 +18,15 @@ import javax.inject.Inject class DisclaimerViewModel @Inject constructor( private val disclaimerStorage: DisclaimerStorage ) : ViewModel() { - private val _isAccepted = MutableLiveData(false) - val isAccepted: LiveData = _isAccepted + val isAccepted: LiveData + get() = disclaimerStorage.isDisclaimerAcceptedFlow.asLiveData() private val _okayCountDown = MutableLiveData(FULL_COUNT_DOWN_SEC) val okayCountDown: LiveData = _okayCountDown - fun checkIsAccepted() { - Timber.v("checkIsAccepted() called") - viewModelScope.launch { - _isAccepted.value = disclaimerStorage.isDisclaimerAccepted() - } - } - fun acceptDisclaimer() { Timber.v("acceptDisclaimer() called") - viewModelScope.launch { - disclaimerStorage.acceptDisclaimer() - _isAccepted.value = true - } + viewModelScope.launch { disclaimerStorage.acceptDisclaimer() } } fun startCountDown() { 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 f4df870..cf4b556 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 @@ -4,8 +4,8 @@ import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import androidx.fragment.app.viewModels -import androidx.lifecycle.Observer import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import by.kirich1409.viewbindingdelegate.viewBinding @@ -13,6 +13,7 @@ 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.AuthenticationState import gq.kirmanak.mealient.ui.auth.AuthenticationViewModel import gq.kirmanak.mealient.ui.refreshesLiveData import kotlinx.coroutines.flow.collect @@ -22,15 +23,17 @@ import timber.log.Timber class RecipesFragment : Fragment(R.layout.fragment_recipes) { private val binding by viewBinding(FragmentRecipesBinding::bind) private val viewModel by viewModels() + private val authViewModel by activityViewModels() - private val authViewModel by viewModels() - private val authStatuses by lazy { authViewModel.authenticationStatuses() } - private val authStatusObserver = Observer { onAuthStatusChange(it) } - private fun onAuthStatusChange(isAuthenticated: Boolean) { - Timber.v("onAuthStatusChange() called with: isAuthenticated = $isAuthenticated") - if (!isAuthenticated) { - authStatuses.removeObserver(authStatusObserver) - navigateToAuthFragment() + 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()) } } @@ -38,7 +41,6 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) { 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 +54,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 diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt index 1027e7b..2613863 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoFragment.kt @@ -19,58 +19,58 @@ import javax.inject.Inject @AndroidEntryPoint class RecipeInfoFragment : BottomSheetDialogFragment() { - private val binding by viewBinding(FragmentRecipeInfoBinding::bind) - private val arguments by navArgs() - private val viewModel by viewModels() + private val binding by viewBinding(FragmentRecipeInfoBinding::bind) + private val arguments by navArgs() + private val viewModel by viewModels() - @Inject - lateinit var ingredientsAdapter: RecipeIngredientsAdapter + @Inject + lateinit var ingredientsAdapter: RecipeIngredientsAdapter - @Inject - lateinit var instructionsAdapter: RecipeInstructionsAdapter + @Inject + lateinit var instructionsAdapter: RecipeInstructionsAdapter - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - Timber.v("onCreateView() called") - 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 + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle? + ): View { + Timber.v("onCreateView() called") + return FragmentRecipeInfoBinding.inflate(inflater, container, false).root } - viewModel.listsVisibility.observe(viewLifecycleOwner) { - Timber.d("onViewCreated: lists visibility $it") - binding.ingredientsHolder.isVisible = it.areIngredientsVisible - binding.instructionsGroup.isVisible = it.areInstructionsVisible - } - } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + Timber.v("onViewCreated() called") - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = - BottomSheetDialog(requireContext(), R.style.NoShapeBottomSheetDialog) + binding.ingredientsList.adapter = ingredientsAdapter + binding.instructionsList.adapter = instructionsAdapter - 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 + 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) { + 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 + } } - } } diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt index 7195021..92fdec5 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/recipes/info/RecipeInfoViewModel.kt @@ -24,9 +24,11 @@ constructor( ) : ViewModel() { private val _recipeInfo = MutableLiveData() - val recipeInfo: LiveData by ::_recipeInfo + val recipeInfo: LiveData + get() = _recipeInfo private val _listsVisibility = MutableLiveData(RecipeInfoListsVisibility()) - val listsVisibility: LiveData by ::_listsVisibility + val listsVisibility: LiveData + get() = _listsVisibility fun loadRecipeImage(view: ImageView, recipeSlug: String) { Timber.v("loadRecipeImage() called with: view = $view, recipeSlug = $recipeSlug") 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 b5439ca..32af21d 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 @@ -5,6 +5,7 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.navigation.NavDirections import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint import gq.kirmanak.mealient.R @@ -19,10 +20,12 @@ class SplashFragment : Fragment(R.layout.fragment_splash) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState") - viewModel.nextDestination.observe(this) { - Timber.d("onCreate: next destination $it") - findNavController().navigate(it) - } + viewModel.nextDestination.observe(this, ::onNextDestination) + } + + private fun onNextDestination(navDirections: NavDirections) { + Timber.v("onNextDestination() called with: navDirections = $navDirections") + findNavController().navigate(navDirections) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { diff --git a/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashViewModel.kt b/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashViewModel.kt index 95a91f2..c845740 100644 --- a/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashViewModel.kt +++ b/app/src/main/java/gq/kirmanak/mealient/ui/splash/SplashViewModel.kt @@ -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() val nextDestination: LiveData = _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() } diff --git a/app/src/main/res/layout/fragment_authentication.xml b/app/src/main/res/layout/fragment_authentication.xml index 312f448..8355ea2 100644 --- a/app/src/main/res/layout/fragment_authentication.xml +++ b/app/src/main/res/layout/fragment_authentication.xml @@ -25,44 +25,28 @@ + android:id="@+id/password_input_layout" + style="@style/SmallMarginTextInputLayoutStyle" + android:hint="@string/fragment_authentication_input_hint_password" + app:layout_constraintBottom_toTopOf="@+id/button" + app:layout_constraintEnd_toEndOf="parent" + app:endIconMode="password_toggle" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toBottomOf="@+id/email_input_layout"> - + - - - - - -