Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,6 @@ 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.9.1")
implementation("androidx.fragment:fragment-ktx:1.8.8")
}
77 changes: 66 additions & 11 deletions app/src/main/java/otus/homework/coroutines/CatsPresenter.kt
Original file line number Diff line number Diff line change
@@ -1,28 +1,82 @@
package otus.homework.coroutines

import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.SocketTimeoutException

class CatsPresenter(
private val catsService: CatsService
) {

private val scope = CoroutineScope(
Dispatchers.Main.immediate + SupervisorJob() + CoroutineName("CatsCoroutine")
)
private var _catsView: ICatsView? = null


/**
* Не понял по условию задачи, что нужно делать, если в одном из запросов ошибка,
* а вдругом нет и на чьи ошибки надо показывать тосты. Тут тосты показываются только на ошибки
* в запросе фактов, и ошибка в одном из запросов не отменяет другой
*
* Во вьюмоделе сделаю, чтоб ошибка в любом из запросов отменяет оба запроса и ошибки из любого
* выводятся в текстовое поле
*/
fun onInitComplete() {
catsService.getCatFact().enqueue(object : Callback<Fact> {
scope.coroutineContext.cancelChildren()
val factJob = scope.async(Dispatchers.IO) {
try {
val response = catsService.getCatFact()
if (response.isSuccessful) {
response.body()
} else {
null
}
} catch (e: Throwable) {
if (e is CancellationException) throw e
when (e) {
is SocketTimeoutException -> {
withContext(Dispatchers.Main) {
_catsView?.showToast("Не удалось получить ответ от сервера")
}
}

override fun onResponse(call: Call<Fact>, response: Response<Fact>) {
if (response.isSuccessful && response.body() != null) {
_catsView?.populate(response.body()!!)
else -> {
CrashMonitor.trackWarning()
withContext(Dispatchers.Main) {
_catsView?.showToast(e.message)
}
}
}
null
}
}

override fun onFailure(call: Call<Fact>, t: Throwable) {
CrashMonitor.trackWarning()
val imageJob = scope.async(Dispatchers.IO) {
try {
val response = catsService.getRandomImage()
if (response.isSuccessful) {
response.body()?.firstOrNull()
} else {
null
}
} catch (e: Throwable) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

нет обработки SocketTimeoutException

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

А там четко не сказано, что это надо для картинок и что вообще должно происходить, когда ошибка только в одном из запросов. Какое условие такой и результат

if (e is CancellationException) throw e
null
}
})
}

scope.launch {
val fact = factJob.await()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

тут не выполнено требование одновременной отмены запросов, и launch и оба async запущены на одной SupervisorJob и отмена одной загрузки никак не повлияет на другую

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Там нет такого требования там написано: "Отменятся запросы должны одновременно". Там не написано отменять оба запроса в случае ошибки в любом из них. Отменяю я запросы при закрытии экрана вместе с гибелью скоупа. Это происходит одновременно

val image = imageJob.await()
_catsView?.populate(CatsUiModel(fact?.fact, image?.url))
}
}

fun attachView(catsView: ICatsView) {
Expand All @@ -31,5 +85,6 @@ class CatsPresenter(

fun detachView() {
_catsView = null
scope.coroutineContext.cancelChildren()
}
}
9 changes: 7 additions & 2 deletions app/src/main/java/otus/homework/coroutines/CatsService.kt
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
package otus.homework.coroutines

import retrofit2.Call
import retrofit2.Response
import retrofit2.http.GET

interface CatsService {

@GET("fact")
fun getCatFact() : Call<Fact>
suspend fun getCatFact(): Response<Fact>

@GET("https://api.thecatapi.com/v1/images/search")
suspend fun getRandomImage(): Response<List<Image>>


}
3 changes: 3 additions & 0 deletions app/src/main/java/otus/homework/coroutines/CatsUiModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package otus.homework.coroutines

data class CatsUiModel(val fact: String? = null, val image: String? = null)
40 changes: 36 additions & 4 deletions app/src/main/java/otus/homework/coroutines/CatsView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,62 @@ 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,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ConstraintLayout(context, attrs, defStyleAttr), ICatsView {

var presenter :CatsPresenter? = null
var presenter: CatsPresenter? = null
private lateinit var imageView: ImageView
private lateinit var textView: TextView
var onClick: (() -> Unit)? = null

override fun onFinishInflate() {
super.onFinishInflate()
findViewById<Button>(R.id.button).setOnClickListener {
presenter?.onInitComplete()
onClick?.invoke()
}
imageView = findViewById(R.id.fact_imageView)
textView = findViewById(R.id.fact_textView)
}

override fun populate(fact: Fact) {
findViewById<TextView>(R.id.fact_textView).text = fact.fact
override fun populate(uiModel: CatsUiModel) {
textView.text = uiModel.fact
Picasso.get().load(uiModel.image).into(imageView)
}

override fun showToast(text: String?) {
text?.let {
Toast.makeText(context, text, Toast.LENGTH_SHORT).show()
}
}

fun bindError(message: String?) {
textView.text = message
imageView.setImageResource(0)
}

fun bindSuccess(uiModel: CatsUiModel) {
textView.text = uiModel.fact
Picasso.get().load(uiModel.image).into(imageView)
}

fun bindLoading() {
textView.text = null
imageView.setImageResource(0)
}
}

interface ICatsView {

fun populate(fact: Fact)
fun populate(uiModel: CatsUiModel)
fun showToast(text: String?)
}
73 changes: 73 additions & 0 deletions app/src/main/java/otus/homework/coroutines/CatsViewModel.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package otus.homework.coroutines

import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.net.SocketTimeoutException

class CatsViewModel(private val catsService: CatsService) : ViewModel() {
private val _uiModelFlow = MutableStateFlow<Result>(Result.Loading)
val uiModelFlow: StateFlow<Result> = _uiModelFlow.asStateFlow()
private val exceptionHandler = CoroutineExceptionHandler { _, e ->
when (e) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

в exceptionHandler лучше не обрабатывать ошибки, а только делать логирование, что и указано в условиях задачи, сюда должны попасть неперехваченные в try-catch ошибки

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Тогда стэйт ошибки не дойдет никогда до ui. Либо внутри async ловить ошибки, тогда они не дойдут до exceptionHandler-а, чтобы там залогироваться. Либо давать им уходить в exceptionHandler и если там только логировать, то стэйт не обновиться.

is SocketTimeoutException -> {
_uiModelFlow.tryEmit(Result.Error(message = "Не удалось получить ответ от сервера"))
}

else -> {
CrashMonitor.trackWarning()
_uiModelFlow.tryEmit(Result.Error(message = e.message))
}
}
}

fun load() {
viewModelScope.coroutineContext.cancelChildren()
viewModelScope.launch(exceptionHandler) {
_uiModelFlow.emit(Result.Loading)
val factJob = async(Dispatchers.IO) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

хорошая практика передавать Dispatchers через конструктор вьюмодели, чтобы его можно было подменять (например в тестах)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Не спорю, не было такой задачи

val response = catsService.getCatFact()
if (response.isSuccessful) {
response.body()
} else {
null
}
}

val imageJob = async(Dispatchers.IO) {
val response = catsService.getRandomImage()
if (response.isSuccessful) {
response.body()?.firstOrNull()
} else {
null
}
}

val factResult = factJob.await()
val imageResult = imageJob.await()
_uiModelFlow.emit(
Result.Success(
CatsUiModel(
fact = factResult?.fact,
image = imageResult?.url
)
)
)
}
}

class Factory(private val catsService: CatsService) : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CatsViewModel(catsService) as T
}
}
}
14 changes: 14 additions & 0 deletions app/src/main/java/otus/homework/coroutines/Image.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
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,
@field:SerializedName("width")
val width: Int,
@field:SerializedName("height")
val height: Int,
)
23 changes: 22 additions & 1 deletion app/src/main/java/otus/homework/coroutines/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -1,24 +1,45 @@
package otus.homework.coroutines

import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach

class MainActivity : AppCompatActivity() {

lateinit var catsPresenter: CatsPresenter

private val diContainer = DiContainer()
private val viewModel by viewModels<CatsViewModel>(
factoryProducer = { CatsViewModel.Factory(diContainer.service) }
)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.load()

val view = layoutInflater.inflate(R.layout.activity_main, null) as CatsView
setContentView(view)

catsPresenter = CatsPresenter(diContainer.service)

view.onClick = { viewModel.load() }
/** код для варианта с презентером
view.presenter = catsPresenter
catsPresenter.attachView(view)
catsPresenter.onInitComplete()
*/
viewModel.uiModelFlow
.onEach { result ->
when (result) {
is Result.Error -> view.bindError(result.message)
Result.Loading -> view.bindLoading()
is Result.Success -> view.bindSuccess(result.uiModel)
}
}
.launchIn(lifecycleScope)
}

override fun onStop() {
Expand Down
7 changes: 7 additions & 0 deletions app/src/main/java/otus/homework/coroutines/Result.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package otus.homework.coroutines

sealed interface Result {
data class Success(val uiModel: CatsUiModel) : Result
data class Error(val message: String?) : Result
object Loading : Result

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

минорно, для удобства лучше сделать data object

Copy link
Author

@Denis-Iuferov Denis-Iuferov Aug 16, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Только ради генерации toString()? Ну да, хуже не будет.) Но для этого надо котлин поднять в проекте, так что пожалуй не буду

}
15 changes: 14 additions & 1 deletion app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@
android:layout_height="match_parent"
tools:context=".MainActivity">

<ImageView
android:id="@+id/fact_imageView"
android:layout_width="200dp"
android:layout_height="200dp"
android:contentDescription="@null"
app:layout_constraintBottom_toTopOf="@+id/fact_textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_chainStyle="packed"
tools:background="@color/black" />

<TextView
android:id="@+id/fact_textView"
android:textColor="@color/black"
Expand All @@ -16,7 +28,8 @@
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
app:layout_constraintTop_toBottomOf="@+id/fact_imageView"
tools:text="Some text" />

<Button
android:id="@+id/button"
Expand Down