Replace Timber with Logger

This commit is contained in:
Kirill Kamakin
2022-08-05 20:16:29 +02:00
parent ba5f7322ab
commit 107bb64256
49 changed files with 458 additions and 260 deletions

View File

@@ -102,8 +102,6 @@ dependencies {
implementation(libs.jetbrains.kotlinx.serialization)
implementation(libs.jakewharton.timber)
implementation(libs.androidx.paging.runtimeKtx)
testImplementation(libs.androidx.paging.commonKtx)

View File

@@ -2,14 +2,17 @@ package gq.kirmanak.mealient
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@HiltAndroidApp
class App : Application() {
@Inject
lateinit var logger: Logger
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
Timber.v("onCreate() called")
logger.v { "onCreate() called" }
}
}

View File

@@ -11,9 +11,9 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import gq.kirmanak.mealient.BuildConfig
import gq.kirmanak.mealient.logging.Logger
import okhttp3.Interceptor
import okhttp3.logging.HttpLoggingInterceptor
import timber.log.Timber
import javax.inject.Singleton
@Module
@@ -22,8 +22,8 @@ object DebugModule {
@Provides
@Singleton
@IntoSet
fun provideLoggingInterceptor(): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> Timber.tag("OkHttp").v(message) }
fun provideLoggingInterceptor(logger: Logger): Interceptor {
val interceptor = HttpLoggingInterceptor { message -> logger.v(tag = "OkHttp") { message } }
interceptor.level = when {
BuildConfig.LOG_NETWORK -> HttpLoggingInterceptor.Level.BODY
else -> HttpLoggingInterceptor.Level.BASIC

View File

@@ -18,7 +18,9 @@ class AddRecipeDataSourceImpl @Inject constructor(
logger.v { "addRecipe() called with: recipe = $recipe" }
val service = addRecipeServiceFactory.provideService()
val response = logAndMapErrors(
block = { service.addRecipe(recipe) }, logProvider = { "addRecipe: can't add recipe" }
logger,
block = { service.addRecipe(recipe) },
logProvider = { "addRecipe: can't add recipe" }
)
logger.v { "addRecipe() response = $response" }
return response

View File

@@ -8,10 +8,10 @@ import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.data.add.models.AddRecipeSettings
import gq.kirmanak.mealient.datastore.recipe.AddRecipeDraft
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -19,6 +19,7 @@ import javax.inject.Singleton
class AddRecipeRepoImpl @Inject constructor(
private val addRecipeDataSource: AddRecipeDataSource,
private val addRecipeStorage: AddRecipeStorage,
private val logger: Logger,
) : AddRecipeRepo {
override val addRecipeRequestFlow: Flow<AddRecipeRequest>
@@ -37,7 +38,7 @@ class AddRecipeRepoImpl @Inject constructor(
}
override suspend fun preserve(recipe: AddRecipeRequest) {
Timber.v("preserveRecipe() called with: recipe = $recipe")
logger.v { "preserveRecipe() called with: recipe = $recipe" }
val input = AddRecipeDraft(
recipeName = recipe.name,
recipeDescription = recipe.description,
@@ -51,12 +52,12 @@ class AddRecipeRepoImpl @Inject constructor(
}
override suspend fun clear() {
Timber.v("clear() called")
logger.v { "clear() called" }
addRecipeStorage.clear()
}
override suspend fun saveRecipe(): String {
Timber.v("saveRecipe() called")
logger.v { "saveRecipe() called" }
return addRecipeDataSource.addRecipe(addRecipeRequestFlow.first())
}
}

View File

@@ -7,10 +7,10 @@ import gq.kirmanak.mealient.data.network.NetworkError.Unauthorized
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.decodeErrorBodyOrNull
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import retrofit2.HttpException
import retrofit2.Response
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,14 +18,15 @@ import javax.inject.Singleton
class AuthDataSourceImpl @Inject constructor(
private val authServiceFactory: ServiceFactory<AuthService>,
private val json: Json,
private val logger: Logger,
) : AuthDataSource {
override suspend fun authenticate(username: String, password: String): String {
Timber.v("authenticate() called with: username = $username, password = $password")
logger.v { "authenticate() called with: username = $username, password = $password" }
val authService = authServiceFactory.provideService()
val response = sendRequest(authService, username, password)
val accessToken = parseToken(response)
Timber.v("authenticate() returned: $accessToken")
logger.v { "authenticate() returned: $accessToken" }
return accessToken
}
@@ -34,6 +35,7 @@ class AuthDataSourceImpl @Inject constructor(
username: String,
password: String
): Response<GetTokenResponse> = logAndMapErrors(
logger,
block = { authService.getToken(username = username, password = password) },
logProvider = { "sendRequest: can't get token" },
)
@@ -44,7 +46,7 @@ class AuthDataSourceImpl @Inject constructor(
response.body()?.accessToken ?: throw NotMealie(NullPointerException("Body is null"))
} else {
val cause = HttpException(response)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json)
val errorDetail: ErrorDetail? = response.decodeErrorBodyOrNull(json, logger)
throw when (errorDetail?.detail) {
"Unauthorized" -> Unauthorized(cause)
else -> NotMealie(cause)

View File

@@ -4,9 +4,9 @@ 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.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@@ -14,13 +14,14 @@ import javax.inject.Singleton
class AuthRepoImpl @Inject constructor(
private val authStorage: AuthStorage,
private val authDataSource: AuthDataSource,
private val logger: Logger,
) : AuthRepo {
override val isAuthorizedFlow: Flow<Boolean>
get() = authStorage.authHeaderFlow.map { it != null }
override suspend fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
logger.v { "authenticate() called with: email = $email, password = $password" }
authDataSource.authenticate(email, password)
.let { AUTH_HEADER_FORMAT.format(it) }
.let { authStorage.setAuthHeader(it) }
@@ -35,14 +36,14 @@ class AuthRepoImpl @Inject constructor(
}
override suspend fun logout() {
Timber.v("logout() called")
logger.v { "logout() called" }
authStorage.setEmail(null)
authStorage.setPassword(null)
authStorage.setAuthHeader(null)
}
override suspend fun invalidateAuthHeader() {
Timber.v("invalidateAuthHeader() called")
logger.v { "invalidateAuthHeader() called" }
val email = authStorage.getEmail() ?: return
val password = authStorage.getPassword() ?: return
runCatchingExceptCancel { authenticate(email, password) }

View File

@@ -6,11 +6,11 @@ import androidx.core.content.edit
import gq.kirmanak.mealient.data.auth.AuthStorage
import gq.kirmanak.mealient.datastore.DataStoreModule.Companion.ENCRYPTED
import gq.kirmanak.mealient.extensions.prefsChangeFlow
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.concurrent.Executors
import javax.inject.Inject
import javax.inject.Named
@@ -19,11 +19,12 @@ import javax.inject.Singleton
@Singleton
class AuthStorageImpl @Inject constructor(
@Named(ENCRYPTED) private val sharedPreferences: SharedPreferences,
private val logger: Logger,
) : AuthStorage {
override val authHeaderFlow: Flow<String?>
get() = sharedPreferences
.prefsChangeFlow { getString(AUTH_HEADER_KEY, null) }
.prefsChangeFlow(logger) { getString(AUTH_HEADER_KEY, null) }
.distinctUntilChanged()
private val singleThreadDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
@@ -43,13 +44,13 @@ class AuthStorageImpl @Inject constructor(
key: String,
value: String?
) = withContext(singleThreadDispatcher) {
Timber.v("putString() called with: key = $key, value = $value")
logger.v { "putString() called with: key = $key, value = $value" }
sharedPreferences.edit(commit = true) { putString(key, value) }
}
private suspend fun getString(key: String) = withContext(singleThreadDispatcher) {
val result = sharedPreferences.getString(key, null)
Timber.v("getString() called with: key = $key, returned: $result")
logger.v { "getString() called with: key = $key, returned: $result" }
result
}

View File

@@ -5,20 +5,22 @@ import gq.kirmanak.mealient.data.baseurl.VersionInfo
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.extensions.logAndMapErrors
import gq.kirmanak.mealient.extensions.versionInfo
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VersionDataSourceImpl @Inject constructor(
private val serviceFactory: ServiceFactory<VersionService>,
private val logger: Logger,
) : VersionDataSource {
override suspend fun getVersionInfo(baseUrl: String): VersionInfo {
Timber.v("getVersionInfo() called with: baseUrl = $baseUrl")
logger.v { "getVersionInfo() called with: baseUrl = $baseUrl" }
val service = serviceFactory.provideService(baseUrl)
val response = logAndMapErrors(
logger,
block = { service.getVersion() },
logProvider = { "getVersionInfo: can't request version" }
)

View File

@@ -2,15 +2,16 @@ package gq.kirmanak.mealient.data.disclaimer
import androidx.datastore.preferences.core.Preferences
import gq.kirmanak.mealient.data.storage.PreferencesStorage
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DisclaimerStorageImpl @Inject constructor(
private val preferencesStorage: PreferencesStorage,
private val logger: Logger,
) : DisclaimerStorage {
private val isDisclaimerAcceptedKey: Preferences.Key<Boolean>
@@ -19,14 +20,14 @@ class DisclaimerStorageImpl @Inject constructor(
get() = preferencesStorage.valueUpdates(isDisclaimerAcceptedKey).map { it == true }
override suspend fun isDisclaimerAccepted(): Boolean {
Timber.v("isDisclaimerAccepted() called")
logger.v { "isDisclaimerAccepted() called" }
val isAccepted = preferencesStorage.getValue(isDisclaimerAcceptedKey) ?: false
Timber.v("isDisclaimerAccepted() returned: $isAccepted")
logger.v { "isDisclaimerAccepted() returned: $isAccepted" }
return isAccepted
}
override suspend fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called")
logger.v { "acceptDisclaimer() called" }
preferencesStorage.storeValues(Pair(isDisclaimerAcceptedKey, true))
}
}

View File

@@ -1,21 +1,22 @@
package gq.kirmanak.mealient.data.network
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import retrofit2.Retrofit
import timber.log.Timber
class RetrofitBuilder(
private val okHttpClient: OkHttpClient,
private val json: Json
private val json: Json,
private val logger: Logger,
) {
@OptIn(ExperimentalSerializationApi::class)
fun buildRetrofit(baseUrl: String): Retrofit {
Timber.v("buildRetrofit() called with: baseUrl = $baseUrl")
logger.v { "buildRetrofit() called with: baseUrl = $baseUrl" }
val contentType = "application/json".toMediaType()
val converterFactory = json.asConverterFactory(contentType)
return Retrofit.Builder()

View File

@@ -2,30 +2,34 @@ package gq.kirmanak.mealient.data.network
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
inline fun <reified T> RetrofitBuilder.createServiceFactory(baseURLStorage: BaseURLStorage) =
RetrofitServiceFactory(T::class.java, this, baseURLStorage)
inline fun <reified T> RetrofitBuilder.createServiceFactory(
baseURLStorage: BaseURLStorage,
logger: Logger
) =
RetrofitServiceFactory(T::class.java, this, baseURLStorage, logger)
class RetrofitServiceFactory<T>(
private val serviceClass: Class<T>,
private val retrofitBuilder: RetrofitBuilder,
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : ServiceFactory<T> {
private val cache: MutableMap<String, T> = mutableMapOf()
override suspend fun provideService(baseUrl: String?): T = runCatchingExceptCancel {
Timber.v("provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}")
logger.v { "provideService() called with: baseUrl = $baseUrl, class = ${serviceClass.simpleName}" }
val url = baseUrl ?: baseURLStorage.requireBaseURL()
synchronized(cache) { cache[url] ?: createService(url, serviceClass) }
}.getOrElse {
Timber.e(it, "provideService: can't provide service for $baseUrl")
logger.e(it) { "provideService: can't provide service for $baseUrl" }
throw NetworkError.MalformedUrl(it)
}
private fun createService(url: String, serviceClass: Class<T>): T {
Timber.v("createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}")
logger.v { "createService() called with: url = $url, serviceClass = ${serviceClass.simpleName}" }
val service = retrofitBuilder.buildRetrofit(url).create(serviceClass)
cache[url] = service
return service

View File

@@ -11,20 +11,21 @@ import gq.kirmanak.mealient.extensions.recipeEntity
import gq.kirmanak.mealient.extensions.toRecipeEntity
import gq.kirmanak.mealient.extensions.toRecipeIngredientEntity
import gq.kirmanak.mealient.extensions.toRecipeInstructionEntity
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeStorageImpl @Inject constructor(
private val db: AppDb
private val db: AppDb,
private val logger: Logger,
) : RecipeStorage {
private val recipeDao: RecipeDao by lazy { db.recipeDao() }
override suspend fun saveRecipes(
recipes: List<GetRecipeSummaryResponse>
) = db.withTransaction {
Timber.v("saveRecipes() called with $recipes")
logger.v { "saveRecipes() called with $recipes" }
val tagEntities = mutableSetOf<TagEntity>()
tagEntities.addAll(recipeDao.queryAllTags())
@@ -91,12 +92,12 @@ class RecipeStorageImpl @Inject constructor(
override fun queryRecipes(): PagingSource<Int, RecipeSummaryEntity> {
Timber.v("queryRecipes() called")
logger.v { "queryRecipes() called" }
return recipeDao.queryRecipesByPages()
}
override suspend fun refreshAll(recipes: List<GetRecipeSummaryResponse>) {
Timber.v("refreshAll() called with: recipes = $recipes")
logger.v { "refreshAll() called with: recipes = $recipes" }
db.withTransaction {
recipeDao.removeAllRecipes()
saveRecipes(recipes)
@@ -104,7 +105,7 @@ class RecipeStorageImpl @Inject constructor(
}
override suspend fun clearAllLocalData() {
Timber.v("clearAllLocalData() called")
logger.v { "clearAllLocalData() called" }
db.withTransaction {
recipeDao.removeAllRecipes()
recipeDao.removeAllCategories()
@@ -113,7 +114,7 @@ class RecipeStorageImpl @Inject constructor(
}
override suspend fun saveRecipeInfo(recipe: GetRecipeResponse) {
Timber.v("saveRecipeInfo() called with: recipe = $recipe")
logger.v { "saveRecipeInfo() called with: recipe = $recipe" }
db.withTransaction {
recipeDao.insertRecipe(recipe.toRecipeEntity())
@@ -132,11 +133,11 @@ class RecipeStorageImpl @Inject constructor(
}
override suspend fun queryRecipeInfo(recipeId: Long): FullRecipeInfo {
Timber.v("queryRecipeInfo() called with: recipeId = $recipeId")
logger.v { "queryRecipeInfo() called with: recipeId = $recipeId" }
val fullRecipeInfo = checkNotNull(recipeDao.queryFullRecipeInfo(recipeId)) {
"Can't find recipe by id $recipeId in DB"
}
Timber.v("queryRecipeInfo() returned: $fullRecipeInfo")
logger.v { "queryRecipeInfo() returned: $fullRecipeInfo" }
return fullRecipeInfo
}
}

View File

@@ -1,18 +1,19 @@
package gq.kirmanak.mealient.data.recipes.impl
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.logging.Logger
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeImageUrlProviderImpl @Inject constructor(
private val baseURLStorage: BaseURLStorage,
private val logger: Logger,
) : RecipeImageUrlProvider {
override suspend fun generateImageUrl(slug: String?): String? {
Timber.v("generateImageUrl() called with: slug = $slug")
logger.v { "generateImageUrl() called with: slug = $slug" }
slug?.takeUnless { it.isBlank() } ?: return null
val imagePath = IMAGE_PATH_FORMAT.format(slug)
val baseUrl = baseURLStorage.getBaseURL()?.takeUnless { it.isEmpty() }
@@ -21,7 +22,7 @@ class RecipeImageUrlProviderImpl @Inject constructor(
?.addPathSegments(imagePath)
?.build()
?.toString()
Timber.v("getRecipeImageUrl() returned: $result")
logger.v { "getRecipeImageUrl() returned: $result" }
return result
}

View File

@@ -10,7 +10,7 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.FullRecipeInfo
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@@ -21,9 +21,10 @@ class RecipeRepoImpl @Inject constructor(
private val storage: RecipeStorage,
private val pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>,
private val dataSource: RecipeDataSource,
private val logger: Logger,
) : RecipeRepo {
override fun createPager(): Pager<Int, RecipeSummaryEntity> {
Timber.v("createPager() called")
logger.v { "createPager() called" }
val pagingConfig = PagingConfig(pageSize = 5, enablePlaceholders = true)
return Pager(
config = pagingConfig,
@@ -33,17 +34,17 @@ class RecipeRepoImpl @Inject constructor(
}
override suspend fun clearLocalData() {
Timber.v("clearLocalData() called")
logger.v { "clearLocalData() called" }
storage.clearAllLocalData()
}
override suspend fun loadRecipeInfo(recipeId: Long, recipeSlug: String): FullRecipeInfo {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" }
runCatchingExceptCancel {
storage.saveRecipeInfo(dataSource.requestRecipeInfo(recipeSlug))
}.onFailure {
Timber.e(it, "loadRecipeInfo: can't update full recipe info")
logger.e(it) { "loadRecipeInfo: can't update full recipe info" }
}
return storage.queryRecipeInfo(recipeId)

View File

@@ -8,7 +8,7 @@ import gq.kirmanak.mealient.data.recipes.db.RecipeStorage
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@@ -18,6 +18,7 @@ class RecipesRemoteMediator @Inject constructor(
private val storage: RecipeStorage,
private val network: RecipeDataSource,
private val pagingSourceFactory: InvalidatingPagingSourceFactory<Int, RecipeSummaryEntity>,
private val logger: Logger,
) : RemoteMediator<Int, RecipeSummaryEntity>() {
@VisibleForTesting
@@ -27,10 +28,10 @@ class RecipesRemoteMediator @Inject constructor(
loadType: LoadType,
state: PagingState<Int, RecipeSummaryEntity>
): MediatorResult {
Timber.v("load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state")
logger.v { "load() called with: lastRequestEnd = $lastRequestEnd, loadType = $loadType, state = $state" }
if (loadType == PREPEND) {
Timber.i("load: early exit, PREPEND isn't supported")
logger.i { "load: early exit, PREPEND isn't supported" }
return MediatorResult.Success(endOfPaginationReached = true)
}
@@ -43,7 +44,7 @@ class RecipesRemoteMediator @Inject constructor(
else storage.saveRecipes(recipes)
recipes.size
}.getOrElse {
Timber.e(it, "load: can't load recipes")
logger.e(it) { "load: can't load recipes" }
return MediatorResult.Error(it)
}
@@ -53,7 +54,7 @@ class RecipesRemoteMediator @Inject constructor(
// Read that trick here https://github.com/android/architecture-components-samples/issues/889#issuecomment-880847858
pagingSourceFactory.invalidate()
Timber.d("load: expectedCount = $limit, received $count")
logger.d { "load: expectedCount = $limit, received $count" }
lastRequestEnd = start + count
return MediatorResult.Success(endOfPaginationReached = count < limit)
}

View File

@@ -3,31 +3,32 @@ package gq.kirmanak.mealient.data.recipes.network
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeResponse
import gq.kirmanak.mealient.data.recipes.network.response.GetRecipeSummaryResponse
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeDataSourceImpl @Inject constructor(
private val recipeServiceFactory: ServiceFactory<RecipeService>,
private val logger: Logger,
) : RecipeDataSource {
override suspend fun requestRecipes(start: Int, limit: Int): List<GetRecipeSummaryResponse> {
Timber.v("requestRecipes() called with: start = $start, limit = $limit")
logger.v { "requestRecipes() called with: start = $start, limit = $limit" }
val recipeSummary = getRecipeService().getRecipeSummary(start, limit)
Timber.v("requestRecipes() returned: $recipeSummary")
logger.v { "requestRecipes() returned: $recipeSummary" }
return recipeSummary
}
override suspend fun requestRecipeInfo(slug: String): GetRecipeResponse {
Timber.v("requestRecipeInfo() called with: slug = $slug")
logger.v { "requestRecipeInfo() called with: slug = $slug" }
val recipeInfo = getRecipeService().getRecipe(slug)
Timber.v("requestRecipeInfo() returned: $recipeInfo")
logger.v { "requestRecipeInfo() returned: $recipeInfo" }
return recipeInfo
}
private suspend fun getRecipeService(): RecipeService {
Timber.v("getRecipeService() called")
logger.v { "getRecipeService() called" }
return recipeServiceFactory.provideService()
}
}

View File

@@ -5,14 +5,15 @@ import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.*
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PreferencesStorageImpl @Inject constructor(
private val dataStore: DataStore<Preferences>
private val dataStore: DataStore<Preferences>,
private val logger: Logger,
) : PreferencesStorage {
override val baseUrlKey = stringPreferencesKey("baseUrl")
@@ -21,7 +22,7 @@ class PreferencesStorageImpl @Inject constructor(
override suspend fun <T> getValue(key: Preferences.Key<T>): T? {
val value = dataStore.data.first()[key]
Timber.v("getValue() returned: $value for $key")
logger.v { "getValue() returned: $value for $key" }
return value
}
@@ -29,23 +30,23 @@ class PreferencesStorageImpl @Inject constructor(
checkNotNull(getValue(key)) { "Value at $key is null when it was required" }
override suspend fun <T> storeValues(vararg pairs: Pair<Preferences.Key<T>, T>) {
Timber.v("storeValues() called with: pairs = ${pairs.contentToString()}")
logger.v { "storeValues() called with: pairs = ${pairs.contentToString()}" }
dataStore.edit { preferences ->
pairs.forEach { preferences += it.toPreferencesPair() }
}
}
override fun <T> valueUpdates(key: Preferences.Key<T>): Flow<T?> {
Timber.v("valueUpdates() called with: key = $key")
logger.v { "valueUpdates() called with: key = $key" }
return dataStore.data
.map { it[key] }
.distinctUntilChanged()
.onEach { Timber.d("valueUpdates: new value at $key is $it") }
.onCompletion { Timber.i(it, "valueUpdates: finished") }
.onEach { logger.d { "valueUpdates: new value at $key is $it" } }
.onCompletion { logger.i(it) { "valueUpdates: finished" } }
}
override suspend fun <T> removeValues(vararg keys: Preferences.Key<T>) {
Timber.v("removeValues() called with: key = ${keys.contentToString()}")
logger.v { "removeValues() called with: key = ${keys.contentToString()}" }
dataStore.edit { preferences ->
keys.forEach { preferences -= it }
}

View File

@@ -16,6 +16,7 @@ import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorage
import gq.kirmanak.mealient.datastore.recipe.AddRecipeStorageImpl
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
@@ -32,9 +33,13 @@ interface AddRecipeModule {
fun provideAddRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AddRecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}

View File

@@ -19,6 +19,7 @@ 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
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
@@ -35,9 +36,13 @@ interface AuthModule {
fun provideAuthServiceFactory(
@Named(NO_AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<AuthService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides

View File

@@ -13,6 +13,7 @@ import gq.kirmanak.mealient.data.baseurl.impl.VersionService
import gq.kirmanak.mealient.data.network.RetrofitBuilder
import gq.kirmanak.mealient.data.network.ServiceFactory
import gq.kirmanak.mealient.data.network.createServiceFactory
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import javax.inject.Named
@@ -29,9 +30,13 @@ interface BaseURLModule {
fun provideVersionServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<VersionService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
}

View File

@@ -5,6 +5,7 @@ import dagger.hilt.EntryPoint
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import okhttp3.OkHttpClient
import java.io.InputStream
import javax.inject.Named
@@ -13,6 +14,8 @@ import javax.inject.Named
@InstallIn(SingletonComponent::class)
interface GlideModuleEntryPoint {
fun provideLogger(): Logger
@Named(AUTH_OK_HTTP)
fun provideOkHttp(): OkHttpClient

View File

@@ -23,6 +23,7 @@ import gq.kirmanak.mealient.data.recipes.network.RecipeDataSource
import gq.kirmanak.mealient.data.recipes.network.RecipeDataSourceImpl
import gq.kirmanak.mealient.data.recipes.network.RecipeService
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeModelLoaderFactory
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
@@ -61,9 +62,13 @@ interface RecipeModule {
fun provideRecipeServiceFactory(
@Named(AUTH_OK_HTTP) okHttpClient: OkHttpClient,
json: Json,
logger: Logger,
baseURLStorage: BaseURLStorage,
): ServiceFactory<RecipeService> {
return RetrofitBuilder(okHttpClient, json).createServiceFactory(baseURLStorage)
return RetrofitBuilder(okHttpClient, json, logger).createServiceFactory(
baseURLStorage,
logger
)
}
@Provides

View File

@@ -1,22 +1,22 @@
package gq.kirmanak.mealient.extensions
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.logging.Logger
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.decodeFromStream
import retrofit2.HttpException
import retrofit2.Response
import timber.log.Timber
import java.io.InputStream
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json): R? =
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it) }
inline fun <T, reified R> Response<T>.decodeErrorBodyOrNull(json: Json, logger: Logger): R? =
errorBody()?.byteStream()?.let { json.decodeFromStreamOrNull<R>(it, logger) }
@OptIn(ExperimentalSerializationApi::class)
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream): T? =
inline fun <reified T> Json.decodeFromStreamOrNull(stream: InputStream, logger: Logger): T? =
runCatching { decodeFromStream<T>(stream) }
.onFailure { Timber.e(it, "decodeFromStreamOrNull: can't decode") }
.onFailure { logger.e(it) { "decodeFromStreamOrNull: can't decode" } }
.getOrNull()
fun Throwable.mapToNetworkError(): NetworkError = when (this) {
@@ -24,8 +24,12 @@ fun Throwable.mapToNetworkError(): NetworkError = when (this) {
else -> NetworkError.NoServerConnection(this)
}
inline fun <T> logAndMapErrors(block: () -> T, logProvider: () -> String): T =
inline fun <T> logAndMapErrors(
logger: Logger,
block: () -> T,
noinline logProvider: () -> String
): T =
runCatchingExceptCancel(block).getOrElse {
Timber.e(it, logProvider())
logger.e(it, messageSupplier = logProvider)
throw it.mapToNetworkError()
}

View File

@@ -15,6 +15,7 @@ import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.textfield.TextInputLayout
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.ChannelResult
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.channels.onClosed
@@ -24,61 +25,60 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
fun SwipeRefreshLayout.refreshRequestFlow(): Flow<Unit> = callbackFlow {
Timber.v("refreshRequestFlow() called")
fun SwipeRefreshLayout.refreshRequestFlow(logger: Logger): Flow<Unit> = callbackFlow {
logger.v { "refreshRequestFlow() called" }
val listener = SwipeRefreshLayout.OnRefreshListener {
Timber.v("refreshRequestFlow: listener called")
trySend(Unit).logErrors("refreshesFlow")
logger.v { "refreshRequestFlow: listener called" }
trySend(Unit).logErrors("refreshesFlow", logger)
}
setOnRefreshListener(listener)
awaitClose {
Timber.v("Removing refresh request listener")
logger.v { "Removing refresh request listener" }
setOnRefreshListener(null)
}
}
fun Activity.setSystemUiVisibility(isVisible: Boolean) {
Timber.v("setSystemUiVisibility() called with: isVisible = $isVisible")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) setSystemUiVisibilityV30(isVisible)
else setSystemUiVisibilityV1(isVisible)
fun Activity.setSystemUiVisibility(isVisible: Boolean, logger: Logger) {
logger.v { "setSystemUiVisibility() called with: isVisible = $isVisible" }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) setSystemUiVisibilityV30(isVisible, logger)
else setSystemUiVisibilityV1(isVisible, logger)
}
@Suppress("DEPRECATION")
private fun Activity.setSystemUiVisibilityV1(isVisible: Boolean) {
Timber.v("setSystemUiVisibilityV1() called with: isVisible = $isVisible")
private fun Activity.setSystemUiVisibilityV1(isVisible: Boolean, logger: Logger) {
logger.v { "setSystemUiVisibilityV1() called with: isVisible = $isVisible" }
window.decorView.systemUiVisibility = if (isVisible) 0 else View.SYSTEM_UI_FLAG_FULLSCREEN
}
@RequiresApi(Build.VERSION_CODES.R)
private fun Activity.setSystemUiVisibilityV30(isVisible: Boolean) {
Timber.v("setSystemUiVisibilityV30() called with: isVisible = $isVisible")
private fun Activity.setSystemUiVisibilityV30(isVisible: Boolean, logger: Logger) {
logger.v { "setSystemUiVisibilityV30() called with: isVisible = $isVisible" }
val systemBars = WindowInsets.Type.systemBars()
window.insetsController?.apply { if (isVisible) show(systemBars) else hide(systemBars) }
?: Timber.w("setSystemUiVisibilityV30: insets controller is null")
?: logger.w { "setSystemUiVisibilityV30: insets controller is null" }
}
fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean) {
Timber.v("setActionBarVisibility() called with: isVisible = $isVisible")
fun AppCompatActivity.setActionBarVisibility(isVisible: Boolean, logger: Logger) {
logger.v { "setActionBarVisibility() called with: isVisible = $isVisible" }
supportActionBar?.apply { if (isVisible) show() else hide() }
?: Timber.w("setActionBarVisibility: action bar is null")
?: logger.w { "setActionBarVisibility: action bar is null" }
}
fun TextView.textChangesFlow(): Flow<CharSequence?> = callbackFlow {
Timber.v("textChangesFlow() called")
fun TextView.textChangesFlow(logger: Logger): Flow<CharSequence?> = callbackFlow {
logger.v { "textChangesFlow() called" }
val textWatcher = doAfterTextChanged {
trySend(it).logErrors("textChangesFlow")
trySend(it).logErrors("textChangesFlow", logger)
}
awaitClose {
Timber.d("textChangesFlow: flow is closing")
logger.d { "textChangesFlow: flow is closing" }
removeTextChangedListener(textWatcher)
}
}
fun <T> ChannelResult<T>.logErrors(methodName: String): ChannelResult<T> {
onFailure { Timber.e(it, "$methodName: can't send event") }
onClosed { Timber.e(it, "$methodName: flow has been closed") }
fun <T> ChannelResult<T>.logErrors(methodName: String, logger: Logger): ChannelResult<T> {
onFailure { logger.e(it) { "$methodName: can't send event" } }
onClosed { logger.e(it) { "$methodName: flow has been closed" } }
return this
}
@@ -87,29 +87,31 @@ fun EditText.checkIfInputIsEmpty(
lifecycleOwner: LifecycleOwner,
@StringRes stringId: Int,
trim: Boolean = true,
logger: Logger,
): String? {
val input = if (trim) text?.trim() else text
val text = input?.toString().orEmpty()
Timber.d("Input text is \"$text\"")
logger.d { "Input text is \"$text\"" }
return text.ifEmpty {
inputLayout.error = resources.getString(stringId)
lifecycleOwner.lifecycleScope.launch {
waitUntilNotEmpty()
waitUntilNotEmpty(logger)
inputLayout.error = null
}
null
}
}
suspend fun EditText.waitUntilNotEmpty() {
textChangesFlow().filterNotNull().first { it.isNotEmpty() }
Timber.v("waitUntilNotEmpty() returned")
suspend fun EditText.waitUntilNotEmpty(logger: Logger) {
textChangesFlow(logger).filterNotNull().first { it.isNotEmpty() }
logger.v { "waitUntilNotEmpty() returned" }
}
fun <T> SharedPreferences.prefsChangeFlow(
logger: Logger,
valueReader: SharedPreferences.() -> T,
): Flow<T> = callbackFlow {
fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow")
fun sendValue() = trySend(valueReader()).logErrors("prefsChangeFlow", logger)
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, _ -> sendValue() }
sendValue()
registerOnSharedPreferenceChangeListener(listener)

View File

@@ -10,7 +10,7 @@ import com.bumptech.glide.module.AppGlideModule
import dagger.hilt.android.EntryPointAccessors.fromApplication
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.di.GlideModuleEntryPoint
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import java.io.InputStream
@GlideModule
@@ -18,13 +18,13 @@ class MealieGlideModule : AppGlideModule() {
override fun registerComponents(context: Context, glide: Glide, registry: Registry) {
super.registerComponents(context, glide, registry)
Timber.v("registerComponents() called with: context = $context, glide = $glide, registry = $registry")
getLogger(context).v { "registerComponents() called with: context = $context, glide = $glide, registry = $registry" }
replaceOkHttp(context, registry)
appendRecipeLoader(registry, context)
}
private fun appendRecipeLoader(registry: Registry, context: Context) {
Timber.v("appendRecipeLoader() called with: registry = $registry, context = $context")
getLogger(context).v { "appendRecipeLoader() called with: registry = $registry, context = $context" }
registry.append(
RecipeSummaryEntity::class.java,
InputStream::class.java,
@@ -33,17 +33,15 @@ class MealieGlideModule : AppGlideModule() {
}
private fun replaceOkHttp(context: Context, registry: Registry) {
Timber.v("replaceOkHttp() called with: context = $context, registry = $registry")
getLogger(context).v { "replaceOkHttp() called with: context = $context, registry = $registry" }
val okHttp = getEntryPoint(context).provideOkHttp()
registry.replace(
GlideUrl::class.java,
InputStream::class.java,
OkHttpUrlLoader.Factory(okHttp)
GlideUrl::class.java, InputStream::class.java, OkHttpUrlLoader.Factory(okHttp)
)
}
private fun getEntryPoint(context: Context): GlideModuleEntryPoint {
Timber.v("getEntryPoint() called with: context = $context")
return fromApplication(context, GlideModuleEntryPoint::class.java)
}
private fun getEntryPoint(context: Context): GlideModuleEntryPoint =
fromApplication(context, GlideModuleEntryPoint::class.java)
private fun getLogger(context: Context): Logger = getEntryPoint(context).provideLogger()
}

View File

@@ -13,18 +13,23 @@ import com.google.android.material.shape.MaterialShapeDrawable
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.MainActivityBinding
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : AppCompatActivity() {
private lateinit var binding: MainActivityBinding
private val viewModel by viewModels<MainActivityViewModel>()
private val title: String by lazy { getString(R.string.app_name) }
private val uiState: MainActivityUiState get() = viewModel.uiState
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
binding = MainActivityBinding.inflate(layoutInflater)
setContentView(binding.root)
setSupportActionBar(binding.toolbar)
@@ -36,7 +41,7 @@ class MainActivity : AppCompatActivity() {
}
private fun onNavigationItemSelected(menuItem: MenuItem): Boolean {
Timber.v("onNavigationItemSelected() called with: menuItem = $menuItem")
logger.v { "onNavigationItemSelected() called with: menuItem = $menuItem" }
menuItem.isChecked = true
val deepLink = when (menuItem.itemId) {
R.id.add_recipe -> ADD_RECIPE_DEEP_LINK
@@ -49,19 +54,19 @@ class MainActivity : AppCompatActivity() {
}
private fun onUiStateChange(uiState: MainActivityUiState) {
Timber.v("onUiStateChange() called with: uiState = $uiState")
logger.v { "onUiStateChange() called with: uiState = $uiState" }
supportActionBar?.title = if (uiState.titleVisible) title else null
binding.navigationView.isVisible = uiState.navigationVisible
invalidateOptionsMenu()
}
private fun setToolbarRoundCorner() {
Timber.v("setToolbarRoundCorner() called")
logger.v { "setToolbarRoundCorner() called" }
val drawables = listOf(
binding.toolbarHolder.background as? MaterialShapeDrawable,
binding.toolbar.background as? MaterialShapeDrawable,
)
Timber.d("setToolbarRoundCorner: drawables = $drawables")
logger.d { "setToolbarRoundCorner: drawables = $drawables" }
val radius = resources.getDimension(R.dimen.main_activity_toolbar_corner_radius)
for (drawable in drawables) {
drawable?.apply {
@@ -72,7 +77,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
Timber.v("onCreateOptionsMenu() called with: menu = $menu")
logger.v { "onCreateOptionsMenu() called with: menu = $menu" }
menuInflater.inflate(R.menu.main_toolbar, menu)
menu.findItem(R.id.logout).isVisible = uiState.canShowLogout
menu.findItem(R.id.login).isVisible = uiState.canShowLogin
@@ -80,7 +85,7 @@ class MainActivity : AppCompatActivity() {
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
Timber.v("onOptionsItemSelected() called with: item = $item")
logger.v { "onOptionsItemSelected() called with: item = $item" }
val result = when (item.itemId) {
R.id.login -> {
navigateDeepLink(AUTH_DEEP_LINK)
@@ -96,7 +101,7 @@ class MainActivity : AppCompatActivity() {
}
private fun navigateDeepLink(deepLink: String) {
Timber.v("navigateDeepLink() called with: deepLink = $deepLink")
logger.v { "navigateDeepLink() called with: deepLink = $deepLink" }
findNavController(binding.navHost.id).navigate(deepLink.toUri())
}

View File

@@ -3,15 +3,16 @@ package gq.kirmanak.mealient.ui.activity
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class MainActivityViewModel @Inject constructor(
private val authRepo: AuthRepo,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableLiveData(MainActivityUiState())
@@ -32,7 +33,7 @@ class MainActivityViewModel @Inject constructor(
}
fun logout() {
Timber.v("logout() called")
logger.v { "logout() called" }
viewModelScope.launch { authRepo.logout() }
}
}

View File

@@ -20,8 +20,9 @@ import gq.kirmanak.mealient.databinding.FragmentAddRecipeBinding
import gq.kirmanak.mealient.databinding.ViewSingleInputBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
@@ -30,9 +31,12 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
private val viewModel by viewModels<AddRecipeViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true)
}
@@ -42,12 +46,12 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
}
private fun observeAddRecipeResult() {
Timber.v("observeAddRecipeResult() called")
logger.v { "observeAddRecipeResult() called" }
collectWhenViewResumed(viewModel.addRecipeResult, ::onRecipeSaveResult)
}
private fun onRecipeSaveResult(isSuccessful: Boolean) = with(binding) {
Timber.v("onRecipeSaveResult() called with: isSuccessful = $isSuccessful")
logger.v { "onRecipeSaveResult() called with: isSuccessful = $isSuccessful" }
listOf(clearButton, saveRecipeButton).forEach { it.isEnabled = true }
@@ -60,12 +64,13 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
}
private fun setupViews() = with(binding) {
Timber.v("setupViews() called")
logger.v { "setupViews() called" }
saveRecipeButton.setOnClickListener {
recipeNameInput.checkIfInputIsEmpty(
inputLayout = recipeNameInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_add_recipe_name_error
stringId = R.string.fragment_add_recipe_name_error,
logger = logger,
) ?: return@setOnClickListener
listOf(saveRecipeButton, clearButton).forEach { it.isEnabled = false }
@@ -98,7 +103,7 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
}
private fun inflateInputRow(flow: Flow, @StringRes hintId: Int, text: String? = null) {
Timber.v("inflateInputRow() called with: flow = $flow, hintId = $hintId, text = $text")
logger.v { "inflateInputRow() called with: flow = $flow, hintId = $hintId, text = $text" }
val fragmentRoot = binding.holder
val inputBinding = ViewSingleInputBinding.inflate(layoutInflater, fragmentRoot, false)
val root = inputBinding.root
@@ -116,7 +121,7 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
}
private fun saveValues() = with(binding) {
Timber.v("saveValues() called")
logger.v { "saveValues() called" }
val instructions = parseInputRows(instructionsFlow).map { AddRecipeInstruction(text = it) }
val ingredients = parseInputRows(ingredientsFlow).map { AddRecipeIngredient(note = it) }
val settings = AddRecipeSettings(
@@ -144,7 +149,7 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
.toList()
private fun onSavedInputLoaded(request: AddRecipeRequest) = with(binding) {
Timber.v("onSavedInputLoaded() called with: request = $request")
logger.v { "onSavedInputLoaded() called with: request = $request" }
recipeNameInput.setText(request.name)
recipeDescriptionInput.setText(request.description)
recipeYieldInput.setText(request.recipeYield)
@@ -159,13 +164,13 @@ class AddRecipeFragment : Fragment(R.layout.fragment_add_recipe) {
}
private fun Iterable<String>.showIn(flow: Flow, @StringRes hintId: Int) {
Timber.v("showIn() called with: flow = $flow, hintId = $hintId")
logger.v { "showIn() called with: flow = $flow, hintId = $hintId" }
flow.removeAllViews()
forEach { inflateInputRow(flow = flow, hintId = hintId, text = it) }
}
private fun Flow.removeAllViews() {
Timber.v("removeAllViews() called")
logger.v { "removeAllViews() called" }
for (id in referencedIds.iterator()) {
val view = binding.holder.findViewById<View>(id) ?: continue
removeView(view)

View File

@@ -6,17 +6,18 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.add.AddRecipeRepo
import gq.kirmanak.mealient.data.add.models.AddRecipeRequest
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AddRecipeViewModel @Inject constructor(
private val addRecipeRepo: AddRecipeRepo,
private val logger: Logger,
) : ViewModel() {
private val _addRecipeResultChannel = Channel<Boolean>(Channel.UNLIMITED)
@@ -27,19 +28,19 @@ class AddRecipeViewModel @Inject constructor(
get() = _preservedAddRecipeRequestChannel.receiveAsFlow()
fun loadPreservedRequest() {
Timber.v("loadPreservedRequest() called")
logger.v { "loadPreservedRequest() called" }
viewModelScope.launch { doLoadPreservedRequest() }
}
private suspend fun doLoadPreservedRequest() {
Timber.v("doLoadPreservedRequest() called")
logger.v { "doLoadPreservedRequest() called" }
val request = addRecipeRepo.addRecipeRequestFlow.first()
Timber.d("doLoadPreservedRequest: request = $request")
logger.d { "doLoadPreservedRequest: request = $request" }
_preservedAddRecipeRequestChannel.send(request)
}
fun clear() {
Timber.v("clear() called")
logger.v { "clear() called" }
viewModelScope.launch {
addRecipeRepo.clear()
doLoadPreservedRequest()
@@ -47,16 +48,16 @@ class AddRecipeViewModel @Inject constructor(
}
fun preserve(request: AddRecipeRequest) {
Timber.v("preserve() called with: request = $request")
logger.v { "preserve() called with: request = $request" }
viewModelScope.launch { addRecipeRepo.preserve(request) }
}
fun saveRecipe() {
Timber.v("saveRecipe() called")
logger.v { "saveRecipe() called" }
viewModelScope.launch {
val isSuccessful = runCatchingExceptCancel { addRecipeRepo.saveRecipe() }
.fold(onSuccess = { true }, onFailure = { false })
Timber.d("saveRecipe: isSuccessful = $isSuccessful")
logger.d { "saveRecipe: isSuccessful = $isSuccessful" }
_addRecipeResultChannel.send(isSuccessful)
}
}

View File

@@ -12,19 +12,24 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentAuthenticationBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
private val binding by viewBinding(FragmentAuthenticationBinding::bind)
private val viewModel by viewModels<AuthenticationViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.button.setOnClickListener { onLoginClicked() }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = false, titleVisible = true, navigationVisible = false)
@@ -33,12 +38,13 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
}
private fun onLoginClicked(): Unit = with(binding) {
Timber.v("onLoginClicked() called")
logger.v { "onLoginClicked() called" }
val email: String = emailInput.checkIfInputIsEmpty(
inputLayout = emailInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_email_input_empty,
logger = logger,
) ?: return
val pass: String = passwordInput.checkIfInputIsEmpty(
@@ -46,13 +52,14 @@ class AuthenticationFragment : Fragment(R.layout.fragment_authentication) {
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_authentication_password_input_empty,
trim = false,
logger = logger,
) ?: return
viewModel.authenticate(email, pass)
}
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
Timber.v("onUiStateChange() called with: authUiState = $uiState")
logger.v { "onUiStateChange() called with: authUiState = $uiState" }
if (uiState.isSuccess) {
findNavController().popBackStack()
return

View File

@@ -7,21 +7,22 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.auth.AuthRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class AuthenticationViewModel @Inject constructor(
private val authRepo: AuthRepo,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
fun authenticate(email: String, password: String) {
Timber.v("authenticate() called with: email = $email, password = $password")
logger.v { "authenticate() called with: email = $email, password = $password" }
_uiState.value = OperationUiState.Progress()
viewModelScope.launch {
val result = runCatchingExceptCancel { authRepo.authenticate(email, password) }

View File

@@ -12,9 +12,10 @@ import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.data.network.NetworkError
import gq.kirmanak.mealient.databinding.FragmentBaseUrlBinding
import gq.kirmanak.mealient.extensions.checkIfInputIsEmpty
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
@@ -23,9 +24,12 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
private val viewModel by viewModels<BaseURLViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.button.setOnClickListener(::onProceedClick)
viewModel.uiState.observe(viewLifecycleOwner, ::onUiStateChange)
activityViewModel.updateUiState {
@@ -34,17 +38,18 @@ class BaseURLFragment : Fragment(R.layout.fragment_base_url) {
}
private fun onProceedClick(view: View) {
Timber.v("onProceedClick() called with: view = $view")
logger.v { "onProceedClick() called with: view = $view" }
val url = binding.urlInput.checkIfInputIsEmpty(
inputLayout = binding.urlInputLayout,
lifecycleOwner = viewLifecycleOwner,
stringId = R.string.fragment_baseurl_url_input_empty,
logger = logger,
) ?: return
viewModel.saveBaseUrl(url)
}
private fun onUiStateChange(uiState: OperationUiState<Unit>) = with(binding) {
Timber.v("onUiStateChange() called with: uiState = $uiState")
logger.v { "onUiStateChange() called with: uiState = $uiState" }
if (uiState.isSuccess) {
findNavController().navigate(BaseURLFragmentDirections.actionBaseURLFragmentToRecipesFragment())
return

View File

@@ -8,22 +8,23 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.baseurl.BaseURLStorage
import gq.kirmanak.mealient.data.baseurl.VersionDataSource
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.OperationUiState
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,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableLiveData<OperationUiState<Unit>>(OperationUiState.Initial())
val uiState: LiveData<OperationUiState<Unit>> get() = _uiState
fun saveBaseUrl(baseURL: String) {
Timber.v("saveBaseUrl() called with: baseURL = $baseURL")
logger.v { "saveBaseUrl() called with: baseURL = $baseURL" }
_uiState.value = OperationUiState.Progress()
val hasPrefix = ALLOWED_PREFIXES.any { baseURL.startsWith(it) }
val url = baseURL.takeIf { hasPrefix } ?: WITH_PREFIX_FORMAT.format(baseURL)
@@ -31,13 +32,13 @@ class BaseURLViewModel @Inject constructor(
}
private suspend fun checkBaseURL(baseURL: String) {
Timber.v("checkBaseURL() called with: baseURL = $baseURL")
logger.v { "checkBaseURL() called with: baseURL = $baseURL" }
val result = runCatchingExceptCancel {
// If it returns proper version info then it must be a Mealie
versionDataSource.getVersionInfo(baseURL)
baseURLStorage.storeBaseURL(baseURL)
}
Timber.i("checkBaseURL: result is $result")
logger.i { "checkBaseURL: result is $result" }
_uiState.value = OperationUiState.fromResult(result)
}

View File

@@ -10,40 +10,45 @@ import by.kirich1409.viewbindingdelegate.viewBinding
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentDisclaimerBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class DisclaimerFragment : Fragment(R.layout.fragment_disclaimer) {
private val binding by viewBinding(FragmentDisclaimerBinding::bind)
private val viewModel by viewModels<DisclaimerViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
viewModel.isAccepted.observe(this, ::onAcceptStateChange)
}
private fun onAcceptStateChange(isAccepted: Boolean) {
Timber.v("onAcceptStateChange() called with: isAccepted = $isAccepted")
logger.v { "onAcceptStateChange() called with: isAccepted = $isAccepted" }
if (isAccepted) navigateNext()
}
private fun navigateNext() {
Timber.v("navigateNext() called")
logger.v { "navigateNext() called" }
findNavController().navigate(DisclaimerFragmentDirections.actionDisclaimerFragmentToBaseURLFragment())
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
binding.okay.setOnClickListener {
Timber.v("onViewCreated: okay clicked")
logger.v { "onViewCreated: okay clicked" }
viewModel.acceptDisclaimer()
}
viewModel.okayCountDown.observe(viewLifecycleOwner) {
Timber.d("onViewCreated: new count $it")
logger.d { "onViewCreated: new count $it" }
binding.okay.text = if (it > 0) resources.getQuantityString(
R.plurals.fragment_disclaimer_button_okay_timer, it, it
) else getString(R.string.fragment_disclaimer_button_okay)

View File

@@ -4,19 +4,20 @@ import androidx.annotation.VisibleForTesting
import androidx.lifecycle.*
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.disclaimer.DisclaimerStorage
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.take
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltViewModel
class DisclaimerViewModel @Inject constructor(
private val disclaimerStorage: DisclaimerStorage
private val disclaimerStorage: DisclaimerStorage,
private val logger: Logger,
) : ViewModel() {
val isAccepted: LiveData<Boolean>
@@ -26,12 +27,12 @@ class DisclaimerViewModel @Inject constructor(
private var isCountDownStarted = false
fun acceptDisclaimer() {
Timber.v("acceptDisclaimer() called")
logger.v { "acceptDisclaimer() called" }
viewModelScope.launch { disclaimerStorage.acceptDisclaimer() }
}
fun startCountDown() {
Timber.v("startCountDown() called")
logger.v { "startCountDown() called" }
if (isCountDownStarted) return
isCountDownStarted = true
tickerFlow(COUNT_DOWN_TICK_PERIOD_SEC.toLong(), TimeUnit.SECONDS)
@@ -48,7 +49,7 @@ class DisclaimerViewModel @Inject constructor(
*/
@VisibleForTesting
fun tickerFlow(period: Long, timeUnit: TimeUnit) = flow {
Timber.v("tickerFlow() called with: period = $period, timeUnit = $timeUnit")
logger.v { "tickerFlow() called with: period = $period, timeUnit = $timeUnit" }
val periodMillis = timeUnit.toMillis(period)
var counter = 0
while (true) {

View File

@@ -4,25 +4,42 @@ import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
class RecipeViewHolder(
class RecipeViewHolder private constructor(
private val logger: Logger,
private val binding: ViewHolderRecipeBinding,
private val recipeImageLoader: RecipeImageLoader,
private val clickListener: (RecipeSummaryEntity) -> Unit,
) : RecyclerView.ViewHolder(binding.root) {
@Singleton
class Factory @Inject constructor(
private val logger: Logger,
) {
fun build(
recipeImageLoader: RecipeImageLoader,
binding: ViewHolderRecipeBinding,
clickListener: (RecipeSummaryEntity) -> Unit,
) = RecipeViewHolder(logger, binding, recipeImageLoader, clickListener)
}
private val loadingPlaceholder by lazy {
binding.root.resources.getString(R.string.view_holder_recipe_text_placeholder)
}
fun bind(item: RecipeSummaryEntity?) {
Timber.v("bind() called with: item = $item")
logger.v { "bind() called with: item = $item" }
binding.name.text = item?.name ?: loadingPlaceholder
recipeImageLoader.loadRecipeImage(binding.image, item)
item?.let { entity ->
binding.root.setOnClickListener {
Timber.d("bind: item clicked $entity")
logger.d { "bind: item clicked $entity" }
clickListener(entity)
}
}

View File

@@ -13,27 +13,34 @@ import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.FragmentRecipesBinding
import gq.kirmanak.mealient.extensions.collectWhenViewResumed
import gq.kirmanak.mealient.extensions.refreshRequestFlow
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.activity.MainActivityViewModel
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import gq.kirmanak.mealient.ui.recipes.images.RecipePreloaderFactory
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
class RecipesFragment : Fragment(R.layout.fragment_recipes) {
private val binding by viewBinding(FragmentRecipesBinding::bind)
private val viewModel by viewModels<RecipeViewModel>()
private val activityViewModel by activityViewModels<MainActivityViewModel>()
@Inject
lateinit var logger: Logger
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
@Inject
lateinit var recipePagingAdapterFactory: RecipesPagingAdapter.Factory
@Inject
lateinit var recipePreloaderFactory: RecipePreloaderFactory
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
activityViewModel.updateUiState {
it.copy(loginButtonVisible = true, titleVisible = false, navigationVisible = true)
}
@@ -41,7 +48,7 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
}
private fun navigateToRecipeInfo(recipeSummaryEntity: RecipeSummaryEntity) {
Timber.v("navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity")
logger.v { "navigateToRecipeInfo() called with: recipeSummaryEntity = $recipeSummaryEntity" }
findNavController().navigate(
RecipesFragmentDirections.actionRecipesFragmentToRecipeInfoFragment(
recipeSlug = recipeSummaryEntity.slug,
@@ -51,29 +58,32 @@ class RecipesFragment : Fragment(R.layout.fragment_recipes) {
}
private fun setupRecipeAdapter() {
Timber.v("setupRecipeAdapter() called")
val recipesAdapter = RecipesPagingAdapter(recipeImageLoader, ::navigateToRecipeInfo)
logger.v { "setupRecipeAdapter() called" }
val recipesAdapter = recipePagingAdapterFactory.build(
recipeImageLoader = recipeImageLoader,
clickListener = ::navigateToRecipeInfo
)
with(binding.recipes) {
adapter = recipesAdapter
addOnScrollListener(recipePreloaderFactory.create(recipesAdapter))
}
collectWhenViewResumed(viewModel.pagingData) {
Timber.v("setupRecipeAdapter: received data update")
logger.v { "setupRecipeAdapter: received data update" }
recipesAdapter.submitData(lifecycle, it)
}
collectWhenViewResumed(recipesAdapter.onPagesUpdatedFlow) {
Timber.v("setupRecipeAdapter: pages updated")
logger.v { "setupRecipeAdapter: pages updated" }
binding.refresher.isRefreshing = false
}
collectWhenViewResumed(binding.refresher.refreshRequestFlow()) {
Timber.v("setupRecipeAdapter: received refresh request")
collectWhenViewResumed(binding.refresher.refreshRequestFlow(logger)) {
logger.v { "setupRecipeAdapter: received refresh request" }
recipesAdapter.refresh()
}
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
logger.v { "onDestroyView() called" }
// Prevent RV leaking through mObservers list in adapter
binding.recipes.adapter = null
}

View File

@@ -6,24 +6,40 @@ import androidx.paging.PagingDataAdapter
import androidx.recyclerview.widget.DiffUtil
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.databinding.ViewHolderRecipeBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
class RecipesPagingAdapter(
class RecipesPagingAdapter private constructor(
private val logger: Logger,
private val recipeImageLoader: RecipeImageLoader,
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
private val clickListener: (RecipeSummaryEntity) -> Unit
) : PagingDataAdapter<RecipeSummaryEntity, RecipeViewHolder>(RecipeDiffCallback) {
@Singleton
class Factory @Inject constructor(
private val logger: Logger,
private val recipeViewHolderFactory: RecipeViewHolder.Factory,
) {
fun build(
recipeImageLoader: RecipeImageLoader,
clickListener: (RecipeSummaryEntity) -> Unit,
) = RecipesPagingAdapter(logger, recipeImageLoader, recipeViewHolderFactory, clickListener)
}
override fun onBindViewHolder(holder: RecipeViewHolder, position: Int) {
val item = getItem(position)
holder.bind(item)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeViewHolder {
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context)
val binding = ViewHolderRecipeBinding.inflate(inflater, parent, false)
return RecipeViewHolder(binding, recipeImageLoader, clickListener)
return recipeViewHolderFactory.build(recipeImageLoader, binding, clickListener)
}
private object RecipeDiffCallback : DiffUtil.ItemCallback<RecipeSummaryEntity>() {

View File

@@ -6,17 +6,18 @@ import com.bumptech.glide.Glide
import com.bumptech.glide.request.RequestOptions
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@FragmentScoped
class RecipeImageLoaderImpl @Inject constructor(
private val fragment: Fragment,
private val requestOptions: RequestOptions,
private val logger: Logger,
) : RecipeImageLoader {
override fun loadRecipeImage(view: ImageView, recipe: RecipeSummaryEntity?) {
Timber.v("loadRecipeImage() called with: view = $view, recipe = $recipe")
logger.v { "loadRecipeImage() called with: view = $view, recipe = $recipe" }
Glide.with(fragment).load(recipe).apply(requestOptions).into(view)
}
}

View File

@@ -7,16 +7,32 @@ import com.bumptech.glide.load.model.ModelLoader
import com.bumptech.glide.load.model.stream.BaseGlideUrlLoader
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.runBlocking
import timber.log.Timber
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
class RecipeModelLoader(
class RecipeModelLoader private constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) : BaseGlideUrlLoader<RecipeSummaryEntity>(concreteLoader, cache) {
@Singleton
class Factory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val logger: Logger,
) {
fun build(
concreteLoader: ModelLoader<GlideUrl, InputStream>,
cache: ModelCache<RecipeSummaryEntity, GlideUrl>,
) = RecipeModelLoader(recipeImageUrlProvider, logger, concreteLoader, cache)
}
override fun handles(model: RecipeSummaryEntity): Boolean = true
override fun getUrl(
@@ -25,7 +41,7 @@ class RecipeModelLoader(
height: Int,
options: Options?
): String? {
Timber.v("getUrl() called with: model = $model, width = $width, height = $height, options = $options")
logger.v { "getUrl() called with: model = $model, width = $width, height = $height, options = $options" }
return runBlocking { recipeImageUrlProvider.generateImageUrl(model?.slug) }
}
}

View File

@@ -1,24 +1,24 @@
package gq.kirmanak.mealient.ui.recipes.images
import com.bumptech.glide.load.model.*
import gq.kirmanak.mealient.data.recipes.impl.RecipeImageUrlProvider
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import java.io.InputStream
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class RecipeModelLoaderFactory @Inject constructor(
private val recipeImageUrlProvider: RecipeImageUrlProvider,
private val recipeModelLoaderFactory: RecipeModelLoader.Factory,
private val logger: Logger,
) : ModelLoaderFactory<RecipeSummaryEntity, InputStream> {
private val cache = ModelCache<RecipeSummaryEntity, GlideUrl>()
override fun build(multiFactory: MultiModelLoaderFactory): ModelLoader<RecipeSummaryEntity, InputStream> {
Timber.v("build() called with: multiFactory = $multiFactory")
logger.v { "build() called with: multiFactory = $multiFactory" }
val concreteLoader = multiFactory.build(GlideUrl::class.java, InputStream::class.java)
return RecipeModelLoader(recipeImageUrlProvider, concreteLoader, cache)
return recipeModelLoaderFactory.build(concreteLoader, cache)
}
override fun teardown() {

View File

@@ -8,22 +8,23 @@ import com.bumptech.glide.RequestBuilder
import com.bumptech.glide.request.RequestOptions
import dagger.hilt.android.scopes.FragmentScoped
import gq.kirmanak.mealient.database.recipe.entity.RecipeSummaryEntity
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
class RecipePreloadModelProvider(
private val adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
private val fragment: Fragment,
private val requestOptions: RequestOptions,
private val logger: Logger,
) : ListPreloader.PreloadModelProvider<RecipeSummaryEntity> {
override fun getPreloadItems(position: Int): List<RecipeSummaryEntity> {
Timber.v("getPreloadItems() called with: position = $position")
logger.v { "getPreloadItems() called with: position = $position" }
return adapter.peek(position)?.let { listOf(it) } ?: emptyList()
}
override fun getPreloadRequestBuilder(item: RecipeSummaryEntity): RequestBuilder<*> {
Timber.v("getPreloadRequestBuilder() called with: item = $item")
logger.v { "getPreloadRequestBuilder() called with: item = $item" }
return Glide.with(fragment).load(item).apply(requestOptions)
}
@@ -31,10 +32,11 @@ class RecipePreloadModelProvider(
class Factory @Inject constructor(
private val fragment: Fragment,
private val requestOptions: RequestOptions,
private val logger: Logger,
) {
fun create(
adapter: PagingDataAdapter<RecipeSummaryEntity, *>,
) = RecipePreloadModelProvider(adapter, fragment, requestOptions)
) = RecipePreloadModelProvider(adapter, fragment, requestOptions, logger)
}
}

View File

@@ -14,8 +14,8 @@ import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.databinding.FragmentRecipeInfoBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.images.RecipeImageLoader
import timber.log.Timber
import javax.inject.Inject
@AndroidEntryPoint
@@ -24,8 +24,17 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
private val binding by viewBinding(FragmentRecipeInfoBinding::bind)
private val arguments by navArgs<RecipeInfoFragmentArgs>()
private val viewModel by viewModels<RecipeInfoViewModel>()
private val ingredientsAdapter = RecipeIngredientsAdapter()
private val instructionsAdapter = RecipeInstructionsAdapter()
private val ingredientsAdapter by lazy { recipeIngredientsAdapterFactory.build() }
private val instructionsAdapter by lazy { recipeInstructionsAdapterFactory.build() }
@Inject
lateinit var recipeInstructionsAdapterFactory: RecipeInstructionsAdapter.Factory
@Inject
lateinit var recipeIngredientsAdapterFactory: RecipeIngredientsAdapter.Factory
@Inject
lateinit var logger: Logger
@Inject
lateinit var recipeImageLoader: RecipeImageLoader
@@ -35,13 +44,13 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
Timber.v("onCreateView() called")
logger.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")
logger.v { "onViewCreated() called" }
with(binding) {
ingredientsList.adapter = ingredientsAdapter
@@ -55,7 +64,7 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
}
private fun onUiStateChange(uiState: RecipeInfoUiState) = with(binding) {
Timber.v("onUiStateChange() called")
logger.v { "onUiStateChange() called" }
ingredientsHolder.isVisible = uiState.areIngredientsVisible
instructionsGroup.isVisible = uiState.areInstructionsVisible
uiState.recipeInfo?.let {
@@ -72,7 +81,7 @@ class RecipeInfoFragment : BottomSheetDialogFragment() {
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
logger.v { "onDestroyView() called" }
// Prevent RV leaking through mObservers list in adapter
with(binding) {
ingredientsList.adapter = null

View File

@@ -7,32 +7,33 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import gq.kirmanak.mealient.data.recipes.RecipeRepo
import gq.kirmanak.mealient.extensions.runCatchingExceptCancel
import gq.kirmanak.mealient.logging.Logger
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class RecipeInfoViewModel @Inject constructor(
private val recipeRepo: RecipeRepo,
private val logger: Logger,
) : ViewModel() {
private val _uiState = MutableLiveData(RecipeInfoUiState())
val uiState: LiveData<RecipeInfoUiState> get() = _uiState
fun loadRecipeInfo(recipeId: Long, recipeSlug: String) {
Timber.v("loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug")
logger.v { "loadRecipeInfo() called with: recipeId = $recipeId, recipeSlug = $recipeSlug" }
_uiState.value = RecipeInfoUiState()
viewModelScope.launch {
runCatchingExceptCancel { recipeRepo.loadRecipeInfo(recipeId, recipeSlug) }
.onSuccess {
Timber.d("loadRecipeInfo: received recipe info = $it")
logger.d { "loadRecipeInfo: received recipe info = $it" }
_uiState.value = RecipeInfoUiState(
areIngredientsVisible = it.recipeIngredients.isNotEmpty(),
areInstructionsVisible = it.recipeInstructions.isNotEmpty(),
recipeInfo = it,
)
}
.onFailure { Timber.e(it, "loadRecipeInfo: can't load recipe info") }
.onFailure { logger.e(it) { "loadRecipeInfo: can't load recipe info" } }
}
}
}

View File

@@ -7,17 +7,40 @@ import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.database.recipe.entity.RecipeIngredientEntity
import gq.kirmanak.mealient.databinding.ViewHolderIngredientBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.info.RecipeIngredientsAdapter.RecipeIngredientViewHolder
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
class RecipeIngredientsAdapter :
ListAdapter<RecipeIngredientEntity, RecipeIngredientViewHolder>(RecipeIngredientDiffCallback) {
class RecipeIngredientsAdapter private constructor(
private val recipeIngredientViewHolderFactory: RecipeIngredientViewHolder.Factory,
private val logger: Logger,
) : ListAdapter<RecipeIngredientEntity, RecipeIngredientViewHolder>(RecipeIngredientDiffCallback) {
class RecipeIngredientViewHolder(
private val binding: ViewHolderIngredientBinding
@Singleton
class Factory @Inject constructor(
private val recipeIngredientViewHolderFactory: RecipeIngredientViewHolder.Factory,
private val logger: Logger,
) {
fun build() = RecipeIngredientsAdapter(recipeIngredientViewHolderFactory, logger)
}
class RecipeIngredientViewHolder private constructor(
private val binding: ViewHolderIngredientBinding,
private val logger: Logger,
) : RecyclerView.ViewHolder(binding.root) {
@Singleton
class Factory @Inject constructor(
private val logger: Logger,
) {
fun build(binding: ViewHolderIngredientBinding) =
RecipeIngredientViewHolder(binding, logger)
}
fun bind(item: RecipeIngredientEntity) {
Timber.v("bind() called with: item = $item")
logger.v { "bind() called with: item = $item" }
binding.checkBox.text = item.note
}
}
@@ -35,17 +58,17 @@ class RecipeIngredientsAdapter :
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeIngredientViewHolder {
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context)
return RecipeIngredientViewHolder(
return recipeIngredientViewHolderFactory.build(
ViewHolderIngredientBinding.inflate(inflater, parent, false)
)
}
override fun onBindViewHolder(holder: RecipeIngredientViewHolder, position: Int) {
Timber.v("onBindViewHolder() called with: holder = $holder, position = $position")
logger.v { "onBindViewHolder() called with: holder = $holder, position = $position" }
val item = getItem(position)
Timber.d("onBindViewHolder: item is $item")
logger.d { "onBindViewHolder: item is $item" }
holder.bind(item)
}
}

View File

@@ -8,11 +8,23 @@ import androidx.recyclerview.widget.RecyclerView
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.database.recipe.entity.RecipeInstructionEntity
import gq.kirmanak.mealient.databinding.ViewHolderInstructionBinding
import gq.kirmanak.mealient.logging.Logger
import gq.kirmanak.mealient.ui.recipes.info.RecipeInstructionsAdapter.RecipeInstructionViewHolder
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
class RecipeInstructionsAdapter :
ListAdapter<RecipeInstructionEntity, RecipeInstructionViewHolder>(RecipeInstructionDiffCallback) {
class RecipeInstructionsAdapter private constructor(
private val logger: Logger,
private val recipeInstructionViewHolderFactory: RecipeInstructionViewHolder.Factory,
) : ListAdapter<RecipeInstructionEntity, RecipeInstructionViewHolder>(RecipeInstructionDiffCallback) {
@Singleton
class Factory @Inject constructor(
private val logger: Logger,
private val recipeInstructionViewHolderFactory: RecipeInstructionViewHolder.Factory,
) {
fun build() = RecipeInstructionsAdapter(logger, recipeInstructionViewHolderFactory)
}
private object RecipeInstructionDiffCallback :
DiffUtil.ItemCallback<RecipeInstructionEntity>() {
@@ -27,11 +39,19 @@ class RecipeInstructionsAdapter :
): Boolean = oldItem == newItem
}
class RecipeInstructionViewHolder(
private val binding: ViewHolderInstructionBinding
class RecipeInstructionViewHolder private constructor(
private val binding: ViewHolderInstructionBinding,
private val logger: Logger,
) : RecyclerView.ViewHolder(binding.root) {
@Singleton
class Factory @Inject constructor(private val logger: Logger) {
fun build(binding: ViewHolderInstructionBinding) =
RecipeInstructionViewHolder(binding, logger)
}
fun bind(item: RecipeInstructionEntity, position: Int) {
Timber.v("bind() called with: item = $item, position = $position")
logger.v { "bind() called with: item = $item, position = $position" }
binding.step.text = binding.root.resources.getString(
R.string.view_holder_recipe_instructions_step, position + 1
)
@@ -40,17 +60,17 @@ class RecipeInstructionsAdapter :
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecipeInstructionViewHolder {
Timber.v("onCreateViewHolder() called with: parent = $parent, viewType = $viewType")
logger.v { "onCreateViewHolder() called with: parent = $parent, viewType = $viewType" }
val inflater = LayoutInflater.from(parent.context)
return RecipeInstructionViewHolder(
ViewHolderInstructionBinding.inflate(inflater, parent, false)
return recipeInstructionViewHolderFactory.build(
ViewHolderInstructionBinding.inflate(inflater, parent, false),
)
}
override fun onBindViewHolder(holder: RecipeInstructionViewHolder, position: Int) {
Timber.v("onBindViewHolder() called with: holder = $holder, position = $position")
logger.v { "onBindViewHolder() called with: holder = $holder, position = $position" }
val item = getItem(position)
Timber.d("onBindViewHolder: item is $item")
logger.d { "onBindViewHolder: item is $item" }
holder.bind(item, position)
}
}

View File

@@ -11,38 +11,43 @@ import dagger.hilt.android.AndroidEntryPoint
import gq.kirmanak.mealient.R
import gq.kirmanak.mealient.extensions.setActionBarVisibility
import gq.kirmanak.mealient.extensions.setSystemUiVisibility
import timber.log.Timber
import gq.kirmanak.mealient.logging.Logger
import javax.inject.Inject
@AndroidEntryPoint
class SplashFragment : Fragment(R.layout.fragment_splash) {
private val viewModel by viewModels<SplashViewModel>()
@Inject
lateinit var logger: Logger
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Timber.v("onCreate() called with: savedInstanceState = $savedInstanceState")
logger.v { "onCreate() called with: savedInstanceState = $savedInstanceState" }
viewModel.nextDestination.observe(this, ::onNextDestination)
}
private fun onNextDestination(navDirections: NavDirections) {
Timber.v("onNextDestination() called with: navDirections = $navDirections")
logger.v { "onNextDestination() called with: navDirections = $navDirections" }
findNavController().navigate(navDirections)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
Timber.v("onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState")
logger.v { "onViewCreated() called with: view = $view, savedInstanceState = $savedInstanceState" }
changeFullscreenState(true)
}
override fun onDestroyView() {
super.onDestroyView()
Timber.v("onDestroyView() called")
logger.v { "onDestroyView() called" }
changeFullscreenState(false)
}
private fun changeFullscreenState(isFullscreen: Boolean) {
Timber.v("changeFullscreenState() called with: isFullscreen = $isFullscreen")
(activity as? AppCompatActivity)?.setActionBarVisibility(!isFullscreen)
activity?.setSystemUiVisibility(!isFullscreen)
logger.v { "changeFullscreenState() called with: isFullscreen = $isFullscreen" }
(activity as? AppCompatActivity)?.setActionBarVisibility(!isFullscreen, logger)
activity?.setSystemUiVisibility(!isFullscreen, logger)
}
}