diff --git a/app/build.gradle b/app/build.gradle index 2f99c8790..949583202 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -49,6 +49,8 @@ dependencies { implementation project(path: ':animations') implementation project(path: ':view') implementation project(path: ':collections') + implementation project(path: ':lifecycle') + implementation project(path: ':fragment') testImplementation "junit:junit:$junitVersion" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 87eb472a6..69e5433eb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -31,7 +31,7 @@ android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"> - + diff --git a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/MainAbstractActivity.kt b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/MainAbstractActivity.kt index e6bc36a8d..3f7a4c472 100644 --- a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/MainAbstractActivity.kt +++ b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/MainAbstractActivity.kt @@ -1,6 +1,7 @@ package com.crazylegend.setofusefulkotlinextensions +import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import androidx.activity.viewModels @@ -9,10 +10,8 @@ import androidx.core.os.bundleOf import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.RecyclerView import com.crazylegend.animations.transition.utils.fadeRecyclerTransition -import com.crazylegend.animations.transition.stagger import com.crazylegend.context.getColorCompat import com.crazylegend.context.isGestureNavigationEnabled -import com.crazylegend.context.shortToast import com.crazylegend.coroutines.textChanges import com.crazylegend.customviews.AppRater import com.crazylegend.customviews.autoStart.AutoStartHelper @@ -24,9 +23,13 @@ import com.crazylegend.kotlinextensions.log.debug import com.crazylegend.kotlinextensions.misc.RunCodeEveryXLaunch import com.crazylegend.kotlinextensions.views.asSearchView import com.crazylegend.kotlinextensions.views.setQueryAndExpand -import com.crazylegend.recyclerview.* +import com.crazylegend.lifecycle.repeatingJobOnStarted +import com.crazylegend.recyclerview.RecyclerSwipeItemHandler +import com.crazylegend.recyclerview.addSwipe import com.crazylegend.recyclerview.clickListeners.forItemClickListener -import com.crazylegend.retrofit.retrofitResult.* +import com.crazylegend.recyclerview.generateRecycler +import com.crazylegend.recyclerview.hideOnScroll +import com.crazylegend.retrofit.retrofitResult.RetrofitResult import com.crazylegend.retrofit.throwables.NoConnectionException import com.crazylegend.setofusefulkotlinextensions.adapter.TestModel import com.crazylegend.setofusefulkotlinextensions.adapter.TestPlaceHolderAdapter @@ -55,8 +58,8 @@ class MainAbstractActivity : AppCompatActivity() { private val exampleGeneratedAdapter by lazy { generateRecycler( - ::TestViewHolderShimmer, - CustomizableCardViewBinding::inflate + ::TestViewHolderShimmer, + CustomizableCardViewBinding::inflate ) { item, holder, _, _ -> holder.bind(item) } @@ -66,6 +69,8 @@ class MainAbstractActivity : AppCompatActivity() { InternetDetector(this) } + + private val activityMainBinding by viewBinding(ActivityMainBinding::inflate) private var savedItemAnimator: RecyclerView.ItemAnimator? = null @@ -79,12 +84,14 @@ class MainAbstractActivity : AppCompatActivity() { savedInstanceState?.getString("query", null)?.let { savedQuery = it } activityMainBinding.test.setOnClickListenerCooldown { - testAVM.getposts() + testAVM.getPosts() } - lifecycleScope.launchWhenResumed { - testAVM.posts.collect { + activityMainBinding.recycler.adapter = generatedAdapter + + repeatingJobOnStarted { + /* testAVM.posts.collect { updateUI(it) - } + }*/ } RunCodeEveryXLaunch.runCode(this, 2) { @@ -115,11 +122,11 @@ class MainAbstractActivity : AppCompatActivity() { } AutoStartHelper.checkAutoStart( - this, dialogBundle = bundleOf( + this, dialogBundle = bundleOf( Pair(ConfirmationDialogAutoStart.CANCEL_TEXT, "Dismiss"), Pair(ConfirmationDialogAutoStart.CONFIRM_TEXT, "Allow"), Pair(ConfirmationDialogAutoStart.DO_NOT_SHOW_AGAIN_VISIBILITY, true) - ) + ) ) if (isGestureNavigationEnabled()) { @@ -135,44 +142,31 @@ class MainAbstractActivity : AppCompatActivity() { } private fun updateUI(retrofitResult: RetrofitResult>) { + /* retrofitResult.asMVIResult(testAVM.resultMVI) { getAsSuccess.isNotNullOrEmpty } + testAVM.resultMVI + .result + .onError { retryOnInternetAvailable(it) } + .onSuccess { + activityMainBinding.progressBar.gone() + generatedAdapter.submitList(it) + }*/ - retrofitResult - .onSuccess { - activityMainBinding.recycler.stagger() - activityMainBinding.recycler.replaceAdapterWith( - generatedAdapter, - fade - ) { itemAnimator -> - savedItemAnimator = itemAnimator - } - generatedAdapter.submitList(it) - val wrappedList = it.toMutableList() - activityMainBinding.recycler.addDrag(generatedAdapter, wrappedList) - } - .onLoading { - activityMainBinding.recycler.adapter = testPlaceHolderAdapter - } - .onError { - if (it is NoConnectionException) { - lifecycleScope.launch { - internetDetector.state.collect { - if (it) { - testAVM.getposts() - } - } + + } + + private fun retryOnInternetAvailable(throwable: Throwable) { + if (throwable is NoConnectionException) { + lifecycleScope.launch { + internetDetector.state.collect { hasConnection -> + if (hasConnection) { + testAVM.getPosts() } } - - activityMainBinding.recycler.adapter = generatedAdapter - generatedAdapter.submitList(emptyList()) - } - .onApiError { errorBody, _ -> - shortToast(testAVM.handleApiError(errorBody) ?: "Error") - activityMainBinding.recycler.adapter = generatedAdapter - generatedAdapter.submitList(emptyList()) } + } } + @SuppressLint("MissingSuperCall") override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) savedQuery?.let { outState.putString("query", it) } @@ -188,11 +182,11 @@ class MainAbstractActivity : AppCompatActivity() { searchItem.asSearchView()?.apply { queryHint = "Search by title" getEditTextSearchView?.textChanges(skipInitialValue = true, debounce = 350L) - ?.map { it?.toString() } - ?.onEach { - debug("TEXT $it") - savedQuery = it - }?.launchIn(lifecycleScope) + ?.map { it?.toString() } + ?.onEach { + debug("TEXT $it") + savedQuery = it + }?.launchIn(lifecycleScope) } return super.onCreateOptionsMenu(menu) @@ -206,3 +200,4 @@ class MainAbstractActivity : AppCompatActivity() { + diff --git a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/TestAVM.kt b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/TestAVM.kt index 21bcc99a0..811c8524d 100644 --- a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/TestAVM.kt +++ b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/TestAVM.kt @@ -4,13 +4,20 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.crazylegend.common.randomUUIDstring +import com.crazylegend.kotlinextensions.viewmodel.context import com.crazylegend.retrofit.adapter.RetrofitResultAdapterFactory +import com.crazylegend.retrofit.interceptors.ConnectivityInterceptor +import com.crazylegend.retrofit.randomPhotoIndex import com.crazylegend.retrofit.retrofitResult.RetrofitResult +import com.crazylegend.retrofit.viewstate.ViewState +import com.crazylegend.retrofit.viewstate.ViewStateContract +import com.crazylegend.retrofit.viewstate.asViewStatePayloadWithEvents import com.crazylegend.setofusefulkotlinextensions.adapter.TestModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch -import okhttp3.ResponseBody +import okhttp3.OkHttpClient import retrofit2.Retrofit import retrofit2.converter.moshi.MoshiConverterFactory import retrofit2.create @@ -25,33 +32,47 @@ import retrofit2.create * Template created by Hristijan to live long and prosper. */ -class TestAVM(application: Application, private val savedStateHandle: SavedStateHandle) : AndroidViewModel(application) { +class TestAVM(application: Application, val savedStateHandle: SavedStateHandle) : AndroidViewModel(application), + ViewStateContract> by ViewState() { - companion object { - private const val errorStateKey = "errorJSONKey" - } + val posts: StateFlow>> = data - private val postsData = MutableStateFlow>>(RetrofitResult.Idle) - val posts = postsData.asStateFlow() + fun getPosts() { + viewModelScope.launch { + setLoading() + delay(2000) + fetchPosts() + } + } - fun getposts() { - postsData.value = RetrofitResult.Loading + fun getRandomPosts() { viewModelScope.launch { - postsData.value = retrofit.getPosts() + setLoading() + delay(2000) + RetrofitResult.Success(listOf( + TestModel(randomUUIDstring, randomPhotoIndex, randomUUIDstring, randomPhotoIndex), + TestModel(randomUUIDstring, randomPhotoIndex, randomUUIDstring, randomPhotoIndex), + TestModel(randomUUIDstring, randomPhotoIndex, randomUUIDstring, randomPhotoIndex), + TestModel(randomUUIDstring, randomPhotoIndex, randomUUIDstring, randomPhotoIndex), + )).asViewStatePayloadWithEvents(this@TestAVM) } } - fun handleApiError(errorBody: ResponseBody?): String? { - val json = errorBody?.string() - if (json.isNullOrEmpty()) return savedStateHandle.get(errorStateKey) - savedStateHandle[errorStateKey] = json + private suspend fun setLoading() { + RetrofitResult.Loading.asViewStatePayloadWithEvents(this) + } - return savedStateHandle.get(errorStateKey) + private suspend fun fetchPosts() { + retrofit.getPosts().asViewStatePayloadWithEvents(this) } private val retrofit by lazy { - with(Retrofit.Builder()){ + with(Retrofit.Builder()) { + client(with(OkHttpClient.Builder()) { + addInterceptor(ConnectivityInterceptor(context)) + build() + }) baseUrl(TestApi.API) addCallAdapterFactory(RetrofitResultAdapterFactory()) addConverterFactory(MoshiConverterFactory.create()) @@ -60,10 +81,8 @@ class TestAVM(application: Application, private val savedStateHandle: SavedState } init { - getposts() + getPosts() } } - - diff --git a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/NavActivity.kt b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/NavActivity.kt index df50f0213..dbdb62f41 100644 --- a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/NavActivity.kt +++ b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/NavActivity.kt @@ -16,4 +16,9 @@ class NavActivity : AppCompatActivity() { private val currentNavController get() = binding.fragmentContainer.getFragment().navController override fun onSupportNavigateUp() = currentNavController.navigateUp() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(binding.root) + } } \ No newline at end of file diff --git a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/TestFragment.kt b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/TestFragment.kt index 7d7fe887f..8aae07f8a 100644 --- a/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/TestFragment.kt +++ b/app/src/main/java/com/crazylegend/setofusefulkotlinextensions/nav/TestFragment.kt @@ -2,16 +2,33 @@ package com.crazylegend.setofusefulkotlinextensions.nav import android.os.Bundle import android.view.View +import android.widget.Toast +import android.widget.Toast.LENGTH_LONG +import androidx.core.view.isVisible import androidx.fragment.app.Fragment -import androidx.navigation.fragment.findNavController -import com.crazylegend.collections.generateRandomStringList -import com.crazylegend.customviews.databinding.CustomizableCardViewBinding -import com.crazylegend.kotlinextensions.log.debug -import com.crazylegend.recyclerview.clickListeners.forItemClickListener -import com.crazylegend.recyclerview.generateRecyclerWithHolder +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.crazylegend.common.ifTrue +import com.crazylegend.fragment.viewCoroutineScope +import com.crazylegend.internetdetector.InternetDetector +import com.crazylegend.lifecycle.repeatingJobOnStarted +import com.crazylegend.retrofit.retrofitResult.* +import com.crazylegend.retrofit.throwables.isNoConnectionException +import com.crazylegend.retrofit.viewstate.ViewEvent +import com.crazylegend.retrofit.viewstate.handleApiError +import com.crazylegend.retrofit.viewstate.isError import com.crazylegend.setofusefulkotlinextensions.R +import com.crazylegend.setofusefulkotlinextensions.TestAVM +import com.crazylegend.setofusefulkotlinextensions.adapter.TestModel +import com.crazylegend.setofusefulkotlinextensions.adapter.TestViewBindingAdapter import com.crazylegend.setofusefulkotlinextensions.databinding.FragmentTestBinding +import com.crazylegend.view.setIsNotRefreshing +import com.crazylegend.view.setIsRefreshing +import com.crazylegend.view.setOnClickListenerCooldown import com.crazylegend.viewbinding.viewBinding +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch /** * Created by Hristijan, date 2/15/21 @@ -20,32 +37,84 @@ class TestFragment : Fragment(R.layout.fragment_test) { private val binding by viewBinding(FragmentTestBinding::bind) - private val testAdapter by lazy { - generateRecyclerWithHolder(CustomizableCardViewBinding::inflate){ - item, position, _, binding, _ -> - binding.title.text = "Title ${position+1}" - binding.content.text = item - } + private val adapter by lazy { + TestViewBindingAdapter() + } + private val internetDetector by lazy { + InternetDetector(requireContext()) } + private val testAVM by viewModels() + + private var errorSnackBar: Snackbar? = null + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - binding.recycler.adapter = testAdapter - testAdapter.submitList(generateRandomStringList(100)) - testAdapter.forItemClickListener = forItemClickListener { _, _, _ -> - findNavController().navigate(R.id.openDetails) + binding.recycler.adapter = adapter + + repeatingJobOnStarted { + testAVM.posts.collect { retrofitResult -> + updateUIstate(retrofitResult) + } + } + repeatingJobOnStarted { + testAVM.viewEvent.collect { viewEvent -> + handleViewEvents(viewEvent) + } + } + + binding.swipeToRefresh.setOnRefreshListener { + binding.swipeToRefresh.setIsRefreshing() + testAVM.getPosts() + binding.swipeToRefresh.setIsNotRefreshing() + } + binding.test.setOnClickListenerCooldown { + testAVM.getRandomPosts() + } + } + + private fun handleViewEvents(viewEvent: ViewEvent) { + if (viewEvent.isError && testAVM.isDataLoaded) { + showErrorSnack() } + } + + + private fun updateUIstate(retrofitResult: RetrofitResult>) { + retrofitResult + .onApiError { errorBody, _ -> + Toast.makeText(requireContext(), handleApiError(testAVM.savedStateHandle, errorBody), LENGTH_LONG).show() + } + .onError { retryOnInternetAvailable(it) } + binding.text.isVisible = testAVM.isDataNotLoaded and (retrofitResult.isError || retrofitResult.isApiError) + binding.centerBigLoading.isVisible = retrofitResult.isLoading and testAVM.isDataNotLoaded + binding.progress.isVisible = retrofitResult.isLoading and testAVM.isDataLoaded + adapter.submitList(testAVM.payload) + } + + private fun showErrorSnack() { + errorSnackBar = Snackbar.make(requireView(), "Error has occurred", Snackbar.LENGTH_LONG) + errorSnackBar?.show() } override fun onDestroyView() { - binding.text.text = "WATAFAK" super.onDestroyView() - debug { "ON DESTROY VIEW IS CALLED" } + errorSnackBar = null + } + private fun retryOnInternetAvailable(throwable: Throwable) { + throwable.isNoConnectionException + .ifTrue { + viewCoroutineScope.launch { observeInternetConnectivity() } + } } - override fun onDestroy() { - super.onDestroy() - debug { "ON DESTROY IS CALLED" } + private suspend fun observeInternetConnectivity() { + internetDetector.state.collect { hasConnection -> + if (hasConnection) { + testAVM.getPosts() + } + } } + } \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 0ad9e9c57..b10f195e0 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -30,6 +30,15 @@ android:layout_height="match_parent" app:layout_behavior="@string/appbar_scrolling_view_behavior"> + + + + - - + android:layout_height="match_parent"> - + + + + + + + + + - \ No newline at end of file + + \ No newline at end of file diff --git a/retrofit/build.gradle b/retrofit/build.gradle index bb6de03e3..11c31c32b 100644 --- a/retrofit/build.gradle +++ b/retrofit/build.gradle @@ -1,5 +1,7 @@ dependencies { implementation project(path: ':common') + implementation project(path: ':coroutines') + implementation "androidx.lifecycle:lifecycle-viewmodel-savedstate:$lifecycle" //retrofit api "com.squareup.retrofit2:retrofit:$retrofit" diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/RetrofitUtils.kt b/retrofit/src/main/java/com/crazylegend/retrofit/RetrofitUtils.kt index 426502d23..83983774f 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/RetrofitUtils.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/RetrofitUtils.kt @@ -382,6 +382,7 @@ fun OkHttpClient.Builder.setUnSafeOkHttpClient() { try { // Create a trust manager that does not validate certificate chains val trustAllCerts: Array = arrayOf( + @SuppressLint("CustomX509TrustManager") object : X509TrustManager { @SuppressLint("TrustAllX509TrustManager") @Throws(CertificateException::class) diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/ApiErrorInterceptor.kt b/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/ApiErrorInterceptor.kt deleted file mode 100644 index b40ea03f9..000000000 --- a/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/ApiErrorInterceptor.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.crazylegend.retrofit.interceptors - -import okhttp3.Interceptor -import okhttp3.Response - - -/** - * Created by hristijan on 8/5/19 to long live and prosper ! - */ - -class ApiErrorInterceptor(val onError: (Response) -> Unit) : Interceptor { - - override fun intercept(chain: Interceptor.Chain): Response { - - val response = chain.proceed(chain.request()) - - return when { - !response.isSuccessful -> { - onError(response) - Response.Builder().build() - } - else -> response - } - } -} \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/BearerAuthenticatorFromProvider.kt b/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/BearerAuthenticatorFromProvider.kt new file mode 100644 index 000000000..49ce684e0 --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/BearerAuthenticatorFromProvider.kt @@ -0,0 +1,21 @@ +package com.crazylegend.retrofit.interceptors + +import com.crazylegend.retrofit.throwables.NoConnectionException +import okhttp3.Interceptor +import okhttp3.Response +import java.io.IOException + + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +class BearerAuthenticatorFromProvider(private val token: () -> String, private val abbreviation: String = "Bearer") : Interceptor { + + @Throws(NoConnectionException::class, IOException::class) + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val authenticatedRequest = request.newBuilder().header("Authorization", "$abbreviation ${token()}").build() + return chain.proceed(authenticatedRequest) + } + +} \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/RetryRequestInterceptor.kt b/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/RetryRequestInterceptor.kt index 79fd321f2..fef239a94 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/RetryRequestInterceptor.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/interceptors/RetryRequestInterceptor.kt @@ -8,16 +8,16 @@ import java.io.IOException /** * Created by Hristijan on 1/25/19 to long live and prosper ! */ -class RetryRequestInterceptor : Interceptor { +class RetryRequestInterceptor(private val maxTries:Int = 3) : Interceptor { @Throws(IOException::class) override fun intercept(chain: Interceptor.Chain): Response { val request = chain.request() var response: Response = chain.proceed(request) - var tryCount = 0 - while (!response.isSuccessful && tryCount < 3) { + var tryCount = 0 + while (!response.isSuccessful && tryCount < maxTries) { tryCount++ response = chain.proceed(request) } diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/responsecode/ResponseCode.kt b/retrofit/src/main/java/com/crazylegend/retrofit/responsecode/ResponseCode.kt new file mode 100644 index 000000000..958aa3ca6 --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/responsecode/ResponseCode.kt @@ -0,0 +1,56 @@ +package com.crazylegend.retrofit.responsecode + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +sealed interface ResponseCode { + object MovedPermanently : ResponseCode + object BadRequest : ResponseCode + object Unauthorized : ResponseCode + object Forbidden : ResponseCode + object NotFound : ResponseCode + object NotAllowed : ResponseCode + object NotAcceptable : ResponseCode + object ProxyAuthenticationRequired : ResponseCode + object Timeout : ResponseCode + object ConflictError : ResponseCode + object RequestPermanentlyDeleted:ResponseCode + object RequestTooLarge : ResponseCode + object AccountExists : ResponseCode + object ServerIsBusy : ResponseCode + object TooManyRequests :ResponseCode + object InternalServerError : ResponseCode + object NotImplemented : ResponseCode + object BadGateway : ResponseCode + object GatewayTimeout : ResponseCode + object AuthenticationRequired : ResponseCode + + object GenericError : ResponseCode +} + + +fun Int.asResponseCode(): ResponseCode = + when (this) { + 301 -> ResponseCode.MovedPermanently + 400 -> ResponseCode.BadRequest + 401 -> ResponseCode.Unauthorized + 403 -> ResponseCode.Forbidden + 404 -> ResponseCode.NotFound + 405 -> ResponseCode.NotAllowed + 406 -> ResponseCode.NotAcceptable + 407 -> ResponseCode.ProxyAuthenticationRequired + 408 -> ResponseCode.Timeout + 409 -> ResponseCode.ConflictError + 410 -> ResponseCode.RequestPermanentlyDeleted + 413 -> ResponseCode.RequestTooLarge + 422 -> ResponseCode.AccountExists + 425 -> ResponseCode.ServerIsBusy + 429 -> ResponseCode.TooManyRequests + 500 -> ResponseCode.InternalServerError + 501 -> ResponseCode.NotImplemented + 502 -> ResponseCode.BadGateway + 504 -> ResponseCode.GatewayTimeout + 511 -> ResponseCode.AuthenticationRequired + + else -> ResponseCode.GenericError + } \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResult.kt b/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResult.kt index b70fc2010..594cc6b4b 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResult.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResult.kt @@ -8,13 +8,15 @@ import okhttp3.ResponseBody */ sealed class RetrofitResult { - data class Success(val value: T) : RetrofitResult(){ - val isValueAListAndNullOrEmpty get() = value is List<*> && value.isNullOrEmpty() + + data class Success(val value: T) : RetrofitResult() { + val isValueAListAndNullOrEmpty get() = value is List<*> && value.isNullOrEmpty() + val isValueAListAndNotNullOrEmpty get() = value is List<*> && !value.isNullOrEmpty() } // handle UI changes when everything is loaded + object Loading : RetrofitResult() // handle loading state - data class Error(val throwable: Throwable) : RetrofitResult() //this one gets thrown when there's an error on your side + data class Error(val throwable: Throwable) : RetrofitResult() //this one gets thrown when there's an error on your side or an error we throw from http data class ApiError(val responseCode: Int, val errorBody: ResponseBody?) : RetrofitResult() //whenever the api throws an error object Idle : RetrofitResult() - } diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResultExtensions.kt b/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResultExtensions.kt index f872700b6..74a19018e 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResultExtensions.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/retrofitResult/RetrofitResultExtensions.kt @@ -94,7 +94,7 @@ suspend fun RetrofitResult.onSuccessSuspend(function: suspend (model: T) fun RetrofitResult.transform(transformer: (VALUE) -> TRANSFORM) :TRANSFORM? = - getSuccess?.let { value -> transformer(value) } + getAsSuccess?.let { value -> transformer(value) } fun Response.unwrapResponseToModel(): T? = when { @@ -105,12 +105,19 @@ fun Response.unwrapResponseToModel(): T? = when { val RetrofitResult.isLoading get() = this is RetrofitResult.Loading val RetrofitResult.isSuccess get() = this is RetrofitResult.Success val RetrofitResult.isSuccessAndValueIsListAndNullOrEmpty get() = this is RetrofitResult.Success && isValueAListAndNullOrEmpty +val RetrofitResult.isSuccessAndValueIsListAndNotNullOrEmpty get() = this is RetrofitResult.Success && isValueAListAndNotNullOrEmpty val RetrofitResult.isIdle get() = this is RetrofitResult.Idle val RetrofitResult.isApiError get() = this is RetrofitResult.ApiError val RetrofitResult.isError get() = this is RetrofitResult.Error -val RetrofitResult.getSuccess: T? get() = if (this is RetrofitResult.Success) value else null -val RetrofitResult.getThrowable: Throwable? get() = if (this is RetrofitResult.Error) throwable else null -val RetrofitResult.getApiFailureCode: Int? get() = if (this is RetrofitResult.ApiError) responseCode else null -val RetrofitResult.getApiResponseBody: ResponseBody? get() = if (this is RetrofitResult.ApiError) errorBody else null +val RetrofitResult.getAsSuccess: T? get() = if (this is RetrofitResult.Success) value else null +val RetrofitResult.getAsThrowable: Throwable? get() = if (this is RetrofitResult.Error) throwable else null +val RetrofitResult.getAsApiFailureCode: Int? get() = if (this is RetrofitResult.ApiError) responseCode else null +val RetrofitResult.getAsApiResponseBody: ResponseBody? get() = if (this is RetrofitResult.ApiError) errorBody else null + +fun RetrofitResult.asSuccess() = this as RetrofitResult.Success +fun RetrofitResult.asLoading() = this as RetrofitResult.Loading +fun RetrofitResult.asError() = this as RetrofitResult.Error +fun RetrofitResult.asApiError() = this as RetrofitResult.ApiError +fun RetrofitResult.asIdle() = this as RetrofitResult.Idle \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/throwables/NoConnectionException.kt b/retrofit/src/main/java/com/crazylegend/retrofit/throwables/NoConnectionException.kt index 37334dcee..0aba24131 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/throwables/NoConnectionException.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/throwables/NoConnectionException.kt @@ -10,4 +10,6 @@ class NoConnectionException(private val customMessage: String? = null) : IOExcep override val message: String get() = customMessage ?: "No Internet Connection" -} \ No newline at end of file +} + +val Throwable.isNoConnectionException get() = this is NoConnectionException \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/throwables/UnauthorizedException.kt b/retrofit/src/main/java/com/crazylegend/retrofit/throwables/UnauthorizedException.kt index e9cbe9caa..ecc67f3cc 100644 --- a/retrofit/src/main/java/com/crazylegend/retrofit/throwables/UnauthorizedException.kt +++ b/retrofit/src/main/java/com/crazylegend/retrofit/throwables/UnauthorizedException.kt @@ -9,4 +9,6 @@ import java.io.IOException class UnauthorizedException(private val customMessage: String?) : IOException() { override val message: String get() = customMessage ?: "Un-authorized, please check credentials or re-login/authorize" -} \ No newline at end of file +} + +val Throwable.isUnauthorizedException get() = this is UnauthorizedException \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewEvent.kt b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewEvent.kt new file mode 100644 index 000000000..c8188a666 --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewEvent.kt @@ -0,0 +1,18 @@ +package com.crazylegend.retrofit.viewstate + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +sealed interface ViewEvent { + object Success : ViewEvent + object Loading : ViewEvent + object Idle : ViewEvent + object Error : ViewEvent + object ApiError : ViewEvent +} + +val ViewEvent.isLoading get() = this is ViewEvent.Loading +val ViewEvent.isIdle get() = this is ViewEvent.Idle +val ViewEvent.isError get() = this is ViewEvent.Error +val ViewEvent.isApiError get() = this is ViewEvent.ApiError +val ViewEvent.isSuccess get() = this is ViewEvent.Success diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewState.kt b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewState.kt new file mode 100644 index 000000000..0626659df --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewState.kt @@ -0,0 +1,42 @@ +package com.crazylegend.retrofit.viewstate + +import com.crazylegend.retrofit.retrofitResult.* +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.receiveAsFlow + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +class ViewState( + capacity: Int = Channel.BUFFERED, + onBufferOverflow: BufferOverflow = BufferOverflow.SUSPEND, + onUndeliveredElement: ((ViewEvent) -> Unit)? = null, + defaultRetrofitState : RetrofitResult = RetrofitResult.Idle +) : ViewStateContract { + + private val viewEvents: Channel = Channel(capacity, onBufferOverflow, onUndeliveredElement) + override val viewEvent = viewEvents.receiveAsFlow() + + private val dataState : MutableStateFlow> = MutableStateFlow(defaultRetrofitState) + override val data = dataState.asStateFlow() + + override var payload: T? = null + + override suspend fun emitEvent(retrofitResult: RetrofitResult) { + dataState.value = retrofitResult + retrofitResult + .onLoading { viewEvents.send(ViewEvent.Loading) } + .onError { viewEvents.send(ViewEvent.Error) } + .onApiError { _, _ -> viewEvents.send(ViewEvent.ApiError) } + .onIdle { viewEvents.send(ViewEvent.Idle) } + .onSuccess { viewEvents.send(ViewEvent.Success) } + } + + override val isDataLoaded get() = payload != null + + override val isDataNotLoaded get() = !isDataLoaded + +} \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateContract.kt b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateContract.kt new file mode 100644 index 000000000..98a4e2825 --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateContract.kt @@ -0,0 +1,25 @@ +package com.crazylegend.retrofit.viewstate + +import com.crazylegend.retrofit.retrofitResult.RetrofitResult +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +interface ViewStateContract { + val viewEvent: Flow + val data : StateFlow> + + var payload: T? + + fun refreshPayload() { + payload = null + } + + suspend fun emitEvent(retrofitResult: RetrofitResult) + + val isDataLoaded get() = payload != null + + val isDataNotLoaded get() = !isDataLoaded +} \ No newline at end of file diff --git a/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateExtensions.kt b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateExtensions.kt new file mode 100644 index 000000000..295119bbf --- /dev/null +++ b/retrofit/src/main/java/com/crazylegend/retrofit/viewstate/ViewStateExtensions.kt @@ -0,0 +1,56 @@ +package com.crazylegend.retrofit.viewstate + +import androidx.lifecycle.SavedStateHandle +import com.crazylegend.retrofit.retrofitResult.RetrofitResult +import com.crazylegend.retrofit.retrofitResult.onSuccess +import okhttp3.ResponseBody + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ + + +fun RetrofitResult.asViewStatePayload(viewState: ViewStateContract): RetrofitResult { + onSuccess { + viewState.payload = it + } + return this +} + + +fun ViewStateContract.fromRetrofit(retrofitResult: RetrofitResult): ViewStateContract { + retrofitResult.onSuccess { + payload = it + } + return this +} + +suspend fun RetrofitResult.asViewStatePayloadWithEvents(viewState: ViewStateContract): RetrofitResult { + onSuccess { + viewState.payload = it + } + viewState.emitEvent(this) + return this +} + + +suspend fun ViewStateContract.fromRetrofitWithEvents(retrofitResult: RetrofitResult): ViewStateContract { + retrofitResult.onSuccess { + payload = it + } + emitEvent(retrofitResult) + return this +} + + +private const val errorStateKey = "errorJSONKeyRetrofitResult" + +fun SavedStateHandle.handleApiErrorFromSavedState(errorBody: ResponseBody?): String? { + val json = errorBody?.string() + if (json.isNullOrEmpty()) return get(errorStateKey) + this[errorStateKey] = json + + return get(errorStateKey) +} + +fun handleApiError(savedStateHandle: SavedStateHandle, errorBody: ResponseBody?): String? = savedStateHandle.handleApiErrorFromSavedState(errorBody) diff --git a/retrofit/src/test/java/com/crazylegend/retrofit/MainCoroutineRule.kt b/retrofit/src/test/java/com/crazylegend/retrofit/MainCoroutineRule.kt new file mode 100644 index 000000000..7c61c3267 --- /dev/null +++ b/retrofit/src/test/java/com/crazylegend/retrofit/MainCoroutineRule.kt @@ -0,0 +1,28 @@ +package com.crazylegend.retrofit + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.TestCoroutineDispatcher +import kotlinx.coroutines.test.TestCoroutineScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +class MainCoroutineRule(val dispatcher: TestCoroutineDispatcher = TestCoroutineDispatcher()) : + TestWatcher(), + TestCoroutineScope by TestCoroutineScope(dispatcher) { + override fun starting(description: Description?) { + super.starting(description) + Dispatchers.setMain(dispatcher) + } + + override fun finished(description: Description?) { + super.finished(description) + Dispatchers.resetMain() + cleanupTestCoroutines() + dispatcher.cleanupTestCoroutines() + } +} \ No newline at end of file diff --git a/retrofit/src/test/java/com/crazylegend/retrofit/ViewStateTest.kt b/retrofit/src/test/java/com/crazylegend/retrofit/ViewStateTest.kt new file mode 100644 index 000000000..0767d44c5 --- /dev/null +++ b/retrofit/src/test/java/com/crazylegend/retrofit/ViewStateTest.kt @@ -0,0 +1,101 @@ +package com.crazylegend.retrofit + +import com.crazylegend.retrofit.retrofitResult.* +import com.crazylegend.retrofit.throwables.NoConnectionException +import com.crazylegend.retrofit.throwables.isNoConnectionException +import com.crazylegend.retrofit.viewstate.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runBlockingTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +/** + * Created by funkymuse on 11/20/21 to long live and prosper ! + */ +@ExperimentalCoroutinesApi +class ViewStateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + + @Test + fun `view state payload once`() = mainCoroutineRule.runBlockingTest { + + val retrofitResult = RetrofitResult.Success(listOf("")) + val viewState = ViewState>() + + retrofitResult.asViewStatePayloadWithEvents(viewState) + + assert(viewState.isDataLoaded) + assert(viewState.payload != null) + assert(!viewState.isDataNotLoaded) + } + + + @Test + fun `view state payload change`() { + + val testList = listOf("") + val retrofitResult = RetrofitResult.Success(testList) + val viewState = ViewState>() + + assert(viewState.payload == null) + assert(viewState.isDataNotLoaded) + assertEquals("Payload should be null", viewState.payload, null) + + retrofitResult.asViewStatePayload(viewState) + + assert(viewState.payload != null) + assert(viewState.isDataLoaded) + assertEquals("Payload not the same", viewState.payload, testList) + + RetrofitResult.Loading.asViewStatePayload(viewState) + + val newList = listOf("Test", "Test1") + val retrofitResultNew = RetrofitResult.Success(newList) + retrofitResultNew.asViewStatePayload(viewState) + + assertEquals("Payload not the same", newList, viewState.payload) + assert(viewState.payload != null) + assert(viewState.isDataLoaded) + assertEquals("Payload not the same", viewState.payload, newList) + + } + + @Test + fun `view state payload event`() = mainCoroutineRule.runBlockingTest { + + val testList = listOf("") + val retrofitResult = RetrofitResult.Success(testList) + val viewState = ViewState>() + retrofitResult.asViewStatePayloadWithEvents(viewState) + + + val firstItem = viewState.viewEvent.first() + val firstState = viewState.data.first() + assertTrue(firstState.isSuccess) + assertTrue(firstItem.isSuccess) + + RetrofitResult.Loading.asViewStatePayloadWithEvents(viewState) + + val firstItemSecondTime = viewState.viewEvent.first() + val firstStateSecondTime = viewState.data.first() + assertTrue(firstStateSecondTime.isLoading) + assertTrue(firstItemSecondTime.isLoading) + + RetrofitResult.Error(NoConnectionException()).asViewStatePayloadWithEvents(viewState) + + val firstItemThirdTime = viewState.viewEvent.first() + val firstStateThirdTime = viewState.data.first() + assertTrue(firstStateThirdTime.isError) + assertTrue(firstItemThirdTime.isError) + assertTrue(viewState.payload != null) + assertTrue(!firstStateThirdTime.isSuccess) + assertTrue(firstStateThirdTime.asError().throwable.isNoConnectionException) + } + +} \ No newline at end of file