Skip to content

Commit f20c4f5

Browse files
committed
initial work on data flow from repo + error handling
1 parent 73aaf07 commit f20c4f5

File tree

10 files changed

+259
-19
lines changed

10 files changed

+259
-19
lines changed

app/src/main/java/com/monstarlab/arch/data/Repository.kt

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,18 @@ abstract class Repository constructor(
88

99
private var lastFetch = 0L
1010

11+
protected suspend inline fun onShouldFetch(block: suspend () -> Unit) {
12+
if(shouldFetch) block.invoke()
13+
}
14+
1115
protected val shouldFetch: Boolean
1216
get() {
13-
return TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - lastFetch) >= expirationInSeconds
17+
val shouldFetch = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis() - lastFetch) >= expirationInSeconds
18+
// Automatically update this value, since we're running API code in an if() and most likely updating local store
19+
if(shouldFetch) {
20+
lastFetch = System.currentTimeMillis()
21+
}
22+
return shouldFetch
1423
}
1524

16-
protected fun fetched() {
17-
lastFetch = System.currentTimeMillis()
18-
}
19-
20-
2125
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
package com.monstarlab.arch.data
2+
3+
import com.monstarlab.core.domain.error.ErrorModel
4+
import com.monstarlab.core.domain.error.toError
5+
import retrofit2.Response
6+
import java.io.IOException
7+
import kotlin.contracts.ExperimentalContracts
8+
import kotlin.contracts.contract
9+
10+
11+
inline fun <T> safeCall(
12+
block: () -> Response<T>
13+
): RepositoryResult<T> {
14+
val response = block()
15+
val body = response.body()
16+
return when (response.isSuccessful && body != null) {
17+
true -> RepositoryResult.Success(body)
18+
false -> RepositoryResult.Error(response.toError())
19+
}
20+
}
21+
22+
fun <T> Response<out T>.toResult(): RepositoryResult<T> {
23+
val body = this.body()
24+
return when (this.isSuccessful && body != null) {
25+
true -> RepositoryResult.Success(body)
26+
false -> RepositoryResult.Error(this.toError())
27+
}
28+
}
29+
30+
fun <T, R> Response<out T>.toResultAndMap(transform: (T) -> R): RepositoryResult<R> {
31+
val body = this.body()
32+
return when (this.isSuccessful && body != null) {
33+
true -> RepositoryResult.Success(transform(body))
34+
false -> RepositoryResult.Error(this.toError())
35+
}
36+
}
37+
38+
39+
40+
sealed class RepositoryResult<out T> {
41+
data class Success<T>(val value: T): RepositoryResult<T>()
42+
data class Error(val error: ErrorModel.Http): RepositoryResult<Nothing>()
43+
}
44+
45+
fun <T> RepositoryResult<T>.onSuccess(block: (T) -> Unit): RepositoryResult<T> {
46+
if(this is RepositoryResult.Success) block.invoke(value)
47+
return this
48+
}
49+
50+
fun <T> RepositoryResult<T>.onError(block: (ErrorModel.Http) -> Unit): RepositoryResult<T> {
51+
if(this is RepositoryResult.Error) block.invoke(error)
52+
return this
53+
}
54+
55+
fun <T> RepositoryResult<T>.isError(): Boolean {
56+
return this is RepositoryResult.Error
57+
}
58+
59+
val <T> RepositoryResult<T>.errorOrNull: ErrorModel.Http?
60+
get() {
61+
return if(this is RepositoryResult.Error) error else null
62+
}
Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
package com.monstarlab.core.data.repositories
22

3-
import com.monstarlab.arch.data.Repository
3+
import com.monstarlab.arch.data.*
44
import com.monstarlab.core.data.mappers.toEntity
55
import com.monstarlab.core.data.network.Api
66
import com.monstarlab.core.data.storage.PostPreferenceStore
@@ -14,19 +14,17 @@ class PostRepository @Inject constructor(
1414
private val postPreferenceStore: PostPreferenceStore
1515
): Repository() {
1616

17-
fun getPosts(): Flow<List<Post>> = flow {
18-
if(shouldFetch) {
19-
val response = api.getPosts()
20-
if(!response.isSuccessful) {
21-
emit(emptyList<Post>())
17+
suspend fun getPosts(): RepositoryResult<List<Post>> {
18+
onShouldFetch {
19+
val result = api.getPosts().toResultAndMap { list -> list.map { it.toEntity() } }
20+
21+
result.onSuccess { list ->
22+
postPreferenceStore.addAll(list)
2223
}
23-
val entries = response.body()?.map { it.toEntity() }
24-
fetched()
25-
//postPreferenceStore.addAll(entries)
26-
//emit(entries)
27-
} else {
28-
emit(postPreferenceStore.getAll())
24+
25+
if(result.isError()) return result
2926
}
27+
return RepositoryResult.Success(postPreferenceStore.getAll())
3028
}
3129

3230
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.monstarlab.core.data.repositories
2+
3+
import retrofit2.Response
4+
5+
data class RepositoryException(
6+
val code: Int,
7+
val errorBody: String?,
8+
val msg: String
9+
) : RuntimeException(msg)
10+
11+
fun <T> Response<T>.mapToRepositoryException(): RepositoryException {
12+
return com.monstarlab.core.data.repositories.RepositoryException(
13+
code = code(),
14+
errorBody = errorBody()?.string(),
15+
msg = message()
16+
)
17+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
package com.monstarlab.core.domain.error
2+
3+
import retrofit2.Response
4+
import java.io.IOException
5+
import java.net.SocketTimeoutException
6+
import java.net.UnknownHostException
7+
8+
fun <T> Response<T>.toError(): ErrorModel.Http {
9+
return when {
10+
code() == 400 -> ErrorModel.Http.BadRequest
11+
code() == 401 -> ErrorModel.Http.Unauthorized
12+
code() == 403 -> ErrorModel.Http.Forbidden
13+
code() == 404 -> ErrorModel.Http.NotFound
14+
code() == 405 -> ErrorModel.Http.MethodNotAllowed
15+
code() in 500..600 -> ErrorModel.Http.ServerError
16+
else -> ErrorModel.Http.Custom(
17+
code(),
18+
message(),
19+
errorBody()?.string()
20+
)
21+
}
22+
}
23+
24+
fun Throwable.toError(): ErrorModel {
25+
return when(this) {
26+
is SocketTimeoutException -> ErrorModel.Connection.Timeout
27+
is UnknownHostException -> ErrorModel.Connection.UnknownHost
28+
is IOException -> ErrorModel.Connection.IOError
29+
else -> ErrorModel.Unknown
30+
}
31+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package com.monstarlab.core.domain.error
2+
3+
sealed class ErrorModel {
4+
5+
sealed class Http: ErrorModel() {
6+
object BadRequest: Http()
7+
object Unauthorized: Http()
8+
object Forbidden: Http()
9+
object NotFound: Http()
10+
object MethodNotAllowed: Http()
11+
12+
object ServerError: Http()
13+
14+
data class Custom(
15+
val code: Int?,
16+
val message: String?,
17+
val errorBody: String?
18+
): Http()
19+
}
20+
21+
sealed class Connection: ErrorModel() {
22+
object Timeout: Connection()
23+
object IOError: Connection()
24+
object UnknownHost: Connection()
25+
}
26+
27+
object Unknown: ErrorModel()
28+
29+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package com.monstarlab.core.sharedui.errorhandling
2+
3+
4+
data class ViewError(
5+
var title: String,
6+
var message: String
7+
)
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
package com.monstarlab.core.sharedui.errorhandling
2+
3+
import android.content.Context
4+
import android.view.View
5+
import androidx.appcompat.app.AlertDialog
6+
import androidx.fragment.app.Fragment
7+
import com.google.android.material.snackbar.Snackbar
8+
import com.monstarlab.core.data.repositories.RepositoryException
9+
import com.monstarlab.core.domain.error.ErrorModel
10+
import javax.inject.Inject
11+
12+
fun Fragment.showErrorDialog(error: ViewError, cancelable: Boolean = true, dismissAction: (() -> Unit)? = null) {
13+
val builder = AlertDialog.Builder(requireContext())
14+
builder.setTitle(error.title)
15+
builder.setMessage(error.message)
16+
builder.setPositiveButton("Ok") { _, _ ->
17+
ViewErrorController.isShowingError = false
18+
}
19+
builder.setOnDismissListener {
20+
ViewErrorController.isShowingError = false
21+
dismissAction?.invoke()
22+
}
23+
if (!ViewErrorController.isShowingError) {
24+
ViewErrorController.isShowingError = true
25+
val dialog = builder.show()
26+
dialog.setCancelable(cancelable)
27+
dialog.setCanceledOnTouchOutside(cancelable)
28+
}
29+
}
30+
31+
fun Fragment.showErrorSnackbar(view: View, error: ViewError, showAction: Boolean = false, dismissAction: (() -> Unit)? = null) {
32+
val showLength = if (showAction) Snackbar.LENGTH_INDEFINITE else Snackbar.LENGTH_LONG
33+
val snackbar = Snackbar.make(view, error.message, showLength)
34+
if (showAction) {
35+
snackbar.setAction("Ok") {
36+
ViewErrorController.isShowingError = false
37+
dismissAction?.invoke()
38+
}
39+
}
40+
if (!ViewErrorController.isShowingError) {
41+
ViewErrorController.isShowingError = true
42+
snackbar.show()
43+
}
44+
}
45+
46+
fun ErrorModel.mapToViewError(): ViewError {
47+
return when (this) {
48+
is ErrorModel.Http.Forbidden,
49+
is ErrorModel.Http.Unauthorized -> {
50+
ViewError(
51+
title = "Translation.error.errorTitle",
52+
message = "Translation.error.authenticationError",
53+
)
54+
}
55+
is ErrorModel.Http.ServerError -> {
56+
ViewError(
57+
title = "Translation.error.errorTitle",
58+
message = "Translation.error.unknownError",
59+
)
60+
}
61+
is ErrorModel.Http -> {
62+
ViewError(
63+
title = "Translation.error.errorTitle",
64+
message = "Translation.error.unknownError",
65+
)
66+
}
67+
is ErrorModel.Connection -> {
68+
ViewError(
69+
title = "Translation.error.errorTitle",
70+
message = "Translation.error.unknownError",
71+
)
72+
}
73+
else -> {
74+
ViewError(
75+
title = "Translation.error.errorTitle",
76+
message = "Translation.error.unknownError",
77+
)
78+
}
79+
}
80+
}
81+
82+
class ViewErrorController @Inject constructor() {
83+
companion object {
84+
var isShowingError = false
85+
}
86+
}

app/src/main/java/com/monstarlab/features/sample/SampleFragment.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@ package com.monstarlab.features.sample
22

33
import android.os.Bundle
44
import android.view.View
5+
import androidx.lifecycle.viewModelScope
56
import com.google.android.material.snackbar.Snackbar
67
import com.monstarlab.R
78
import com.monstarlab.arch.base.BaseFragment
89
import com.monstarlab.databinding.FragmentSampleBinding
910
import com.monstarlab.arch.extensions.collectFlow
1011
import com.monstarlab.arch.extensions.viewBinding
12+
import kotlinx.coroutines.launch
1113

1214
class SampleFragment : BaseFragment(R.layout.fragment_sample) {
1315

app/src/main/java/com/monstarlab/features/sample/SampleViewModel.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.monstarlab.features.sample
22

3+
import androidx.lifecycle.SavedStateHandle
34
import androidx.lifecycle.ViewModel
45
import androidx.lifecycle.viewModelScope
56
import com.monstarlab.arch.extensions.combineFlows
@@ -14,7 +15,7 @@ import java.io.IOException
1415
import javax.inject.Inject
1516

1617
class SampleViewModel @Inject constructor(
17-
private val postRepository: PostRepository
18+
private val postRepository: PostRepository
1819
): ViewModel() {
1920

2021
val clickFlow: MutableStateFlow<Int> = MutableStateFlow(0)
@@ -28,8 +29,11 @@ class SampleViewModel @Inject constructor(
2829
fun fetchBlogPosts() {
2930
postRepository
3031
.getPosts()
32+
.onStart { /* start loading */ }
3133
.onEach { blogEntries -> textFlow.value = "Found ${blogEntries.size}" }
34+
.retryWhen { cause, attempt -> cause is IOException && attempt <= 2 }
3235
.catch { _ -> errorFlow.emit("Something went wrong") }
36+
.onCompletion { /* stop loading */ }
3337
.launchIn(viewModelScope)
3438
}
3539

0 commit comments

Comments
 (0)