diff --git a/app/build.gradle b/app/build.gradle index a414e0e8..67ea887c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -41,4 +41,5 @@ dependencies { implementation 'com.google.android.material:material:1.11.0' implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.squareup.picasso:picasso:2.71828' + implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.0" } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CatsPresenter.kt b/app/src/main/java/otus/homework/coroutines/CatsPresenter.kt index e4b05120..eea85ada 100644 --- a/app/src/main/java/otus/homework/coroutines/CatsPresenter.kt +++ b/app/src/main/java/otus/homework/coroutines/CatsPresenter.kt @@ -1,28 +1,45 @@ package otus.homework.coroutines -import retrofit2.Call -import retrofit2.Callback -import retrofit2.Response +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import java.net.SocketTimeoutException class CatsPresenter( - private val catsService: CatsService + private val catsService: CatsService, + private val imageService: ImageService, + private val presenterScope: CoroutineScope ) { private var _catsView: ICatsView? = null + private var job: Job? = null fun onInitComplete() { - catsService.getCatFact().enqueue(object : Callback { - - override fun onResponse(call: Call, response: Response) { - if (response.isSuccessful && response.body() != null) { - _catsView?.populate(response.body()!!) - } + job = presenterScope.async { + val image = async { imageService.getCatImage().first() } + val fact = async { catsService.getCatFact() } + + _catsView?.populate(CatsUIState(fact.await(), image.await())) + } + presenterScope.launch { + try { + (job as Deferred<*>).await() + } catch (e: CancellationException) { + throw e + } catch (e: SocketTimeoutException) { + showToast("Не удалось получить ответ от сервера") + } catch (e: Exception) { + CrashMonitor.trackWarning(e) + e.message?.let(::showToast) } + } + } - override fun onFailure(call: Call, t: Throwable) { - CrashMonitor.trackWarning() - } - }) + private fun showToast(message: String) { + _catsView?.showToast(message) } fun attachView(catsView: ICatsView) { @@ -32,4 +49,12 @@ class CatsPresenter( fun detachView() { _catsView = null } + + fun cancelCoroutine() { + job?.run { + if (isActive) { + cancel() + } + } + } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CatsService.kt b/app/src/main/java/otus/homework/coroutines/CatsService.kt index 479b2cfb..db865d0c 100644 --- a/app/src/main/java/otus/homework/coroutines/CatsService.kt +++ b/app/src/main/java/otus/homework/coroutines/CatsService.kt @@ -1,10 +1,9 @@ package otus.homework.coroutines -import retrofit2.Call import retrofit2.http.GET interface CatsService { @GET("fact") - fun getCatFact() : Call + suspend fun getCatFact() : Fact } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CatsUIState.kt b/app/src/main/java/otus/homework/coroutines/CatsUIState.kt new file mode 100644 index 00000000..33451b0d --- /dev/null +++ b/app/src/main/java/otus/homework/coroutines/CatsUIState.kt @@ -0,0 +1,6 @@ +package otus.homework.coroutines + +data class CatsUIState( + val fact: Fact, + val image: Image +) \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CatsView.kt b/app/src/main/java/otus/homework/coroutines/CatsView.kt index be04b2a8..4f87add3 100644 --- a/app/src/main/java/otus/homework/coroutines/CatsView.kt +++ b/app/src/main/java/otus/homework/coroutines/CatsView.kt @@ -3,11 +3,14 @@ package otus.homework.coroutines import android.content.Context import android.util.AttributeSet import android.widget.Button +import android.widget.ImageView import android.widget.TextView +import android.widget.Toast import androidx.constraintlayout.widget.ConstraintLayout +import com.squareup.picasso.Picasso class CatsView @JvmOverloads constructor( - context: Context, + private val context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 ) : ConstraintLayout(context, attrs, defStyleAttr), ICatsView { @@ -21,12 +24,19 @@ class CatsView @JvmOverloads constructor( } } - override fun populate(fact: Fact) { - findViewById(R.id.fact_textView).text = fact.fact + override fun populate(state: CatsUIState) { + findViewById(R.id.fact_textView).text = state.fact.fact + Picasso.get().load(state.image.url).into(findViewById(R.id.catImage)) + } + + override fun showToast(message: String) { + Toast.makeText(context, message, Toast.LENGTH_SHORT).show() } } interface ICatsView { - fun populate(fact: Fact) + fun populate(state: CatsUIState) + + fun showToast(message: String) } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CatsViewModel.kt b/app/src/main/java/otus/homework/coroutines/CatsViewModel.kt new file mode 100644 index 00000000..fccac683 --- /dev/null +++ b/app/src/main/java/otus/homework/coroutines/CatsViewModel.kt @@ -0,0 +1,68 @@ +package otus.homework.coroutines + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.launch +import java.net.SocketTimeoutException + +class CatsViewModel( + private val catsService: CatsService, + private val imageService: ImageService +) : ViewModel() { + + private val exceptionHandler = CoroutineExceptionHandler { _, throwable -> + when(throwable) { + is SocketTimeoutException -> _uiState.tryEmit(Result.Error(Throwable("Не удалось получить ответ от сервера"))) + else -> { + CrashMonitor.trackWarning(throwable) + _uiState.tryEmit(Result.Error(throwable)) + } + } + } + + private val _uiState = MutableStateFlow(null) + val uiState = _uiState.onStart { load() } + .filterNotNull() + .shareIn( + viewModelScope, + SharingStarted.WhileSubscribed(5000), + replay = 1 + ) + + fun load() { + viewModelScope.launch(exceptionHandler) { + val fact = async { catsService.getCatFact() } + val image = async { imageService.getCatImage() } + + _uiState.emit( + Result.Success( + CatsUIState( + fact = fact.await(), + image = image.await()[0] + ) + ) + ) + } + } + + companion object { + fun provideFactory( + catsService: CatsService, + imageService: ImageService + ): ViewModelProvider.Factory = viewModelFactory { + initializer { + CatsViewModel(catsService, imageService) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/CrashMonitor.kt b/app/src/main/java/otus/homework/coroutines/CrashMonitor.kt index 32e6b018..34fd74af 100644 --- a/app/src/main/java/otus/homework/coroutines/CrashMonitor.kt +++ b/app/src/main/java/otus/homework/coroutines/CrashMonitor.kt @@ -5,6 +5,5 @@ object CrashMonitor { /** * Pretend this is Crashlytics/AppCenter */ - fun trackWarning() { - } + fun trackWarning(exception: Throwable) {} } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/DiContainer.kt b/app/src/main/java/otus/homework/coroutines/DiContainer.kt index 23ddc3b2..545845aa 100644 --- a/app/src/main/java/otus/homework/coroutines/DiContainer.kt +++ b/app/src/main/java/otus/homework/coroutines/DiContainer.kt @@ -1,16 +1,30 @@ package otus.homework.coroutines +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory class DiContainer { - private val retrofit by lazy { - Retrofit.Builder() - .baseUrl("https://catfact.ninja/") - .addConverterFactory(GsonConverterFactory.create()) + private val converter by lazy { GsonConverterFactory.create() } + + private fun buildRetrofit(baseUrl: String) = + Retrofit.Builder().baseUrl(baseUrl).addConverterFactory(converter) .build() + + val catsService by lazy { + buildRetrofit("https://catfact.ninja/").create( + CatsService::class.java + ) + } + + val imageService by lazy { + buildRetrofit("https://api.thecatapi.com/v1/images/").create( + ImageService::class.java + ) } - val service by lazy { retrofit.create(CatsService::class.java) } + val presenterScope get() = CoroutineScope(CoroutineName("CatsCoroutine") + Dispatchers.Main) } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/Image.kt b/app/src/main/java/otus/homework/coroutines/Image.kt new file mode 100644 index 00000000..b721bb76 --- /dev/null +++ b/app/src/main/java/otus/homework/coroutines/Image.kt @@ -0,0 +1,10 @@ +package otus.homework.coroutines + +import com.google.gson.annotations.SerializedName + +data class Image( + @field:SerializedName("id") + val id: String, + @field:SerializedName("url") + val url: String +) diff --git a/app/src/main/java/otus/homework/coroutines/ImageService.kt b/app/src/main/java/otus/homework/coroutines/ImageService.kt new file mode 100644 index 00000000..a227195c --- /dev/null +++ b/app/src/main/java/otus/homework/coroutines/ImageService.kt @@ -0,0 +1,8 @@ +package otus.homework.coroutines + +import retrofit2.http.GET + +interface ImageService { + @GET("search") + suspend fun getCatImage(): List +} \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/MainActivity.kt b/app/src/main/java/otus/homework/coroutines/MainActivity.kt index a9dafb3b..d933627e 100644 --- a/app/src/main/java/otus/homework/coroutines/MainActivity.kt +++ b/app/src/main/java/otus/homework/coroutines/MainActivity.kt @@ -1,30 +1,69 @@ package otus.homework.coroutines -import androidx.appcompat.app.AppCompatActivity import android.os.Bundle +import android.widget.Button +import android.widget.ImageView +import android.widget.TextView +import android.widget.Toast +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.squareup.picasso.Picasso +import kotlinx.coroutines.launch +import otus.homework.coroutines.Result.* class MainActivity : AppCompatActivity() { - lateinit var catsPresenter: CatsPresenter - private val diContainer = DiContainer() + private lateinit var fact: TextView + private lateinit var image: ImageView + private lateinit var loadButton: Button + + private val viewModel by lazy { + ViewModelProvider( + this, CatsViewModel.provideFactory(diContainer.catsService, diContainer.imageService) + )[CatsViewModel::class.java] + } + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val view = layoutInflater.inflate(R.layout.activity_main, null) as CatsView + val view = layoutInflater.inflate(R.layout.activity_main, null) setContentView(view) + fact = view.findViewById(R.id.fact_textView) + image = view.findViewById(R.id.catImage) + loadButton = view.findViewById(R.id.button) + + processState() + setListeners() + } - catsPresenter = CatsPresenter(diContainer.service) - view.presenter = catsPresenter - catsPresenter.attachView(view) - catsPresenter.onInitComplete() + private fun setListeners() { + loadButton.setOnClickListener { + viewModel.load() + } } - override fun onStop() { - if (isFinishing) { - catsPresenter.detachView() + private fun processState() { + lifecycleScope.launch { + viewModel.uiState.collect { state -> + when(state) { + is Success<*> -> { + (state.data as CatsUIState).also { + fact.text = it.fact.fact + Picasso.get().load(it.image.url).into(image) + } + } + is Error -> { + showToast(state.error) + viewModel.load() + } + } + } } - super.onStop() + } + + private fun showToast(throwable: Throwable, duration: Int = Toast.LENGTH_LONG) { + Toast.makeText(this, throwable.message, duration).show() } } \ No newline at end of file diff --git a/app/src/main/java/otus/homework/coroutines/Result.kt b/app/src/main/java/otus/homework/coroutines/Result.kt new file mode 100644 index 00000000..d08fd1b5 --- /dev/null +++ b/app/src/main/java/otus/homework/coroutines/Result.kt @@ -0,0 +1,6 @@ +package otus.homework.coroutines + +sealed class Result{ + data class Success(val data: T) : Result() + data class Error(val error: Throwable) : Result() +} \ 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 9508066d..69a84369 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -7,16 +7,26 @@ android:layout_height="match_parent" tools:context=".MainActivity"> + + + app:layout_constraintTop_toBottomOf="@id/catImage" />