Skip to content

Commit b808833

Browse files
committed
add StateViewModel, BoxContent
1 parent 41579f6 commit b808833

File tree

21 files changed

+431
-101
lines changed

21 files changed

+431
-101
lines changed

app/build.gradle.kts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ dependencies {
157157
// https://developer.android.com/kotlin/ktx/extensions-list
158158
implementation("androidx.core:core-ktx:1.9.0")
159159
implementation("androidx.activity:activity-ktx:1.6.1")
160-
implementation("androidx.fragment:fragment-ktx:1.5.4")
160+
implementation("androidx.fragment:fragment-ktx:1.5.5")
161161

162162
// Lifecycle
163163
// https://developer.android.com/jetpack/androidx/releases/lifecycle
@@ -217,8 +217,8 @@ dependencies {
217217
kapt("com.github.bumptech.glide:compiler:4.14.2")
218218

219219
// dagger hilt
220-
implementation("com.google.dagger:hilt-android:2.44")
221-
kapt("com.google.dagger:hilt-android-compiler:2.44")
220+
implementation("com.google.dagger:hilt-android:2.44.2")
221+
kapt("com.google.dagger:hilt-android-compiler:2.44.2")
222222
implementation("androidx.hilt:hilt-navigation-fragment:1.0.0")
223223
// implementation("androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03")
224224
kapt("androidx.hilt:hilt-compiler:1.0.0")
@@ -229,7 +229,7 @@ dependencies {
229229

230230
// firebase
231231
// https://firebase.google.com/docs/android/setup
232-
implementation(platform("com.google.firebase:firebase-bom:31.0.0"))
232+
implementation(platform("com.google.firebase:firebase-bom:31.1.1"))
233233
implementation("com.google.firebase:firebase-analytics-ktx")
234234
implementation("com.google.firebase:firebase-crashlytics-ktx")
235235
implementation("com.google.firebase:firebase-messaging-ktx")
@@ -247,9 +247,9 @@ dependencies {
247247

248248
// unit test
249249
testImplementation("junit:junit:4.13.2")
250-
testImplementation("org.mockito:mockito-core:4.8.1")
250+
testImplementation("org.mockito:mockito-core:4.9.0")
251251
// testImplementation("org.mockito:mockito-inline:3.3.3")
252-
testImplementation("io.mockk:mockk:1.13.2")
252+
testImplementation("io.mockk:mockk:1.13.3")
253253
testImplementation("androidx.arch.core:core-testing:2.1.0")
254254
testImplementation("com.squareup.okhttp3:mockwebserver:5.0.0-alpha.2")
255255
testImplementation("org.jetbrains.kotlin:kotlin-stdlib:1.7.20")
@@ -372,7 +372,7 @@ dependencies {
372372
// compose
373373
// https://developer.android.com/jetpack/compose/interop/adding
374374
// https://developer.android.com/jetpack/compose/setup
375-
val composeBom = platform("androidx.compose:compose-bom:2022.10.00")
375+
val composeBom = platform("androidx.compose:compose-bom:2022.12.00")
376376
implementation(composeBom)
377377
androidTestImplementation(composeBom)
378378
// Android Studio Preview support
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.example.moviedb.compose.ui.base
2+
3+
import com.example.moviedb.data.remote.BaseException
4+
5+
sealed class ErrorEvent(
6+
val type: ErrorType,
7+
val baseException: BaseException? = null
8+
) {
9+
object Network : ErrorEvent(type = ErrorType.NETWORK)
10+
object Timeout : ErrorEvent(type = ErrorType.TIMEOUT)
11+
object Unauthorized : ErrorEvent(type = ErrorType.HTTP_UNAUTHORIZED)
12+
object ForceUpdate : ErrorEvent(type = ErrorType.FORCE_UPDATE)
13+
class Unknown(baseException: BaseException) :
14+
ErrorEvent(type = ErrorType.UNKNOWN, baseException = baseException)
15+
}
16+
17+
enum class ErrorType {
18+
NETWORK,
19+
TIMEOUT,
20+
HTTP_UNAUTHORIZED,
21+
FORCE_UPDATE,
22+
UNKNOWN
23+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.example.moviedb.compose.ui.base
2+
3+
import androidx.lifecycle.ViewModel
4+
import com.example.moviedb.data.remote.toBaseException
5+
import kotlinx.coroutines.flow.MutableStateFlow
6+
import kotlinx.coroutines.flow.StateFlow
7+
import java.net.ConnectException
8+
import java.net.HttpURLConnection
9+
import java.net.SocketTimeoutException
10+
import java.net.UnknownHostException
11+
12+
open class StateViewModel : ViewModel() {
13+
14+
private val _loading by lazy { MutableStateFlow(false) }
15+
val loading: StateFlow<Boolean> = _loading
16+
17+
private val _refreshing by lazy { MutableStateFlow(false) }
18+
val refreshing: StateFlow<Boolean> = _refreshing
19+
20+
private val _errorEvent by lazy { MutableStateFlow<ErrorEvent?>(null) }
21+
val errorEvent: StateFlow<ErrorEvent?> = _errorEvent
22+
23+
fun showLoading() {
24+
_loading.value = true
25+
}
26+
27+
fun hideLoading() {
28+
_loading.value = false
29+
}
30+
31+
fun showRefreshing() {
32+
_refreshing.value = true
33+
}
34+
35+
fun hideRefreshing() {
36+
_refreshing.value = false
37+
}
38+
39+
open fun doRefresh() {
40+
}
41+
42+
protected open fun onError(e: Exception) {
43+
hideLoading()
44+
hideRefreshing()
45+
when (e) {
46+
// case no internet connection
47+
is UnknownHostException -> {
48+
_errorEvent.value = ErrorEvent.Network
49+
}
50+
is ConnectException -> {
51+
_errorEvent.value = ErrorEvent.Network
52+
}
53+
// case request time out
54+
is SocketTimeoutException -> {
55+
_errorEvent.value = ErrorEvent.Timeout
56+
}
57+
else -> {
58+
// convert throwable to base exception to get error information
59+
val baseException = e.toBaseException()
60+
when (baseException.httpCode) {
61+
HttpURLConnection.HTTP_UNAUTHORIZED -> {
62+
_errorEvent.value = ErrorEvent.Unauthorized
63+
}
64+
else -> {
65+
_errorEvent.value = ErrorEvent.Unknown(baseException = baseException)
66+
}
67+
}
68+
}
69+
}
70+
}
71+
72+
fun hideError() {
73+
_errorEvent.value = null
74+
}
75+
}

app/src/main/java/com/example/moviedb/compose/ui/detail/DetailScreen.kt

Lines changed: 74 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,14 @@ import androidx.compose.ui.res.painterResource
2121
import androidx.compose.ui.unit.dp
2222
import androidx.compose.ui.unit.sp
2323
import androidx.hilt.navigation.compose.hiltViewModel
24-
import androidx.lifecycle.viewModelScope
2524
import androidx.navigation.NavController
2625
import com.example.moviedb.R
26+
import com.example.moviedb.compose.ui.widget.BoxContent
2727
import com.example.moviedb.data.model.Movie
28-
import com.example.moviedb.data.repository.UserRepository
29-
import com.example.moviedb.ui.base.BaseViewModel
3028
import com.skydoves.landscapist.ImageOptions
3129
import com.skydoves.landscapist.components.rememberImageComponent
3230
import com.skydoves.landscapist.glide.GlideImage
3331
import com.skydoves.landscapist.placeholder.placeholder.PlaceholderPlugin
34-
import dagger.hilt.android.lifecycle.HiltViewModel
35-
import kotlinx.coroutines.flow.MutableStateFlow
36-
import kotlinx.coroutines.flow.StateFlow
37-
import kotlinx.coroutines.launch
38-
import javax.inject.Inject
3932

4033
@Composable
4134
fun DetailScreen(
@@ -45,74 +38,91 @@ fun DetailScreen(
4538
) {
4639
val movie by viewModel.movie.collectAsState()
4740
LaunchedEffect(key1 = movieId, block = {
41+
viewModel.setValueMovieId(id = movieId)
4842
viewModel.getMovieDetail(movieId = movieId)
4943
})
50-
MovieDetailBody(movie = movie, onClickBack = { navController.popBackStack() })
44+
45+
BoxContent(
46+
enableRefresh = true,
47+
viewModel = viewModel
48+
) {
49+
movie?.let {
50+
MovieDetailBody(
51+
movie = it,
52+
onClickBack = { navController.popBackStack() },
53+
)
54+
} ?: MovieDetailEmptyBody(onClickBack = {
55+
navController.popBackStack()
56+
})
57+
}
5158
}
5259

5360
@Composable
5461
fun MovieDetailBody(
55-
movie: Movie?,
62+
movie: Movie,
5663
onClickBack: () -> Unit
5764
) {
58-
if (movie != null) {
59-
Column(
60-
modifier = Modifier
61-
.fillMaxSize()
62-
.background(Color.Black)
63-
) {
64-
Box(modifier = Modifier.fillMaxWidth()) {
65-
GlideImage(
66-
imageModel = { movie.getFullBackdropPath() ?: "" },
67-
modifier = Modifier.fillMaxWidth(),
68-
component = rememberImageComponent {
69-
+PlaceholderPlugin.Loading(Icons.Filled.Image)
70-
+PlaceholderPlugin.Failure(Icons.Filled.Error)
71-
},
72-
imageOptions = ImageOptions(),
73-
)
74-
Image(
75-
painterResource(R.drawable.ic_arrow_back_white_24dp),
76-
contentDescription = "",
77-
contentScale = ContentScale.Crop,
78-
modifier = Modifier
79-
.size(48.dp)
80-
.clip(CircleShape)
81-
.clickable {
82-
onClickBack.invoke()
83-
}
84-
.padding(12.dp),
85-
)
86-
}
87-
Text(
88-
movie.title ?: "",
89-
color = Color.White,
90-
modifier = Modifier.padding(16.dp),
91-
fontSize = 20.sp,
65+
Column(
66+
modifier = Modifier
67+
.fillMaxSize()
68+
.background(Color.Black)
69+
) {
70+
Box(modifier = Modifier.fillMaxWidth()) {
71+
GlideImage(
72+
imageModel = { movie.getFullBackdropPath() ?: "" },
73+
modifier = Modifier.fillMaxWidth(),
74+
component = rememberImageComponent {
75+
+PlaceholderPlugin.Loading(Icons.Filled.Image)
76+
+PlaceholderPlugin.Failure(Icons.Filled.Error)
77+
},
78+
imageOptions = ImageOptions(),
79+
)
80+
Image(
81+
painterResource(R.drawable.ic_arrow_back_white_24dp),
82+
contentDescription = "",
83+
contentScale = ContentScale.Crop,
84+
modifier = Modifier
85+
.size(48.dp)
86+
.clip(CircleShape)
87+
.clickable {
88+
onClickBack.invoke()
89+
}
90+
.padding(12.dp),
9291
)
93-
Text(movie.releaseDate ?: "", color = Color.White)
94-
Text(movie.overview ?: "", color = Color.White)
9592
}
96-
} else {
97-
93+
Text(
94+
movie.title ?: "",
95+
color = Color.White,
96+
modifier = Modifier.padding(16.dp),
97+
fontSize = 20.sp,
98+
)
99+
Text(movie.releaseDate ?: "", color = Color.White)
100+
Text(movie.overview ?: "", color = Color.White)
98101
}
99102
}
100103

101-
@HiltViewModel
102-
class DetailViewModel @Inject constructor(
103-
private val userRepository: UserRepository
104-
) : BaseViewModel() {
105-
private val _movie = MutableStateFlow<Movie?>(null)
106-
val movie: StateFlow<Movie?> = _movie
107-
108-
fun getMovieDetail(movieId: String?) {
109-
if (movieId.isNullOrBlank()) return
110-
viewModelScope.launch {
111-
try {
112-
_movie.value = userRepository.getMovieById(movieId)
113-
} catch (e: Throwable) {
114-
onError(e)
115-
}
104+
@Composable
105+
fun MovieDetailEmptyBody(
106+
onClickBack: () -> Unit
107+
) {
108+
Column(
109+
modifier = Modifier
110+
.fillMaxSize()
111+
.background(Color.Black)
112+
) {
113+
Box(modifier = Modifier.fillMaxWidth()) {
114+
Image(
115+
painterResource(R.drawable.ic_arrow_back_white_24dp),
116+
contentDescription = "",
117+
contentScale = ContentScale.Crop,
118+
modifier = Modifier
119+
.size(48.dp)
120+
.clip(CircleShape)
121+
.clickable {
122+
onClickBack.invoke()
123+
}
124+
.padding(12.dp),
125+
)
116126
}
117127
}
118-
}
128+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package com.example.moviedb.compose.ui.detail
2+
3+
import androidx.lifecycle.viewModelScope
4+
import com.example.moviedb.compose.ui.base.StateViewModel
5+
import com.example.moviedb.data.model.Movie
6+
import com.example.moviedb.data.repository.UserRepository
7+
import dagger.hilt.android.lifecycle.HiltViewModel
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.launch
11+
import javax.inject.Inject
12+
13+
@HiltViewModel
14+
class DetailViewModel @Inject constructor(
15+
private val userRepository: UserRepository
16+
) : StateViewModel() {
17+
private val _movie by lazy { MutableStateFlow<Movie?>(null) }
18+
val movie: StateFlow<Movie?> = _movie
19+
20+
private var movieId: String? = null
21+
22+
fun setValueMovieId(id: String?) {
23+
movieId = id
24+
}
25+
26+
fun getMovieDetail(movieId: String?) {
27+
if (movieId.isNullOrBlank()) return
28+
viewModelScope.launch {
29+
try {
30+
showLoading()
31+
_movie.value = userRepository.getMovieById(movieId = movieId)
32+
} catch (e: Exception) {
33+
onError(e)
34+
} finally {
35+
hideLoading()
36+
}
37+
}
38+
}
39+
40+
override fun doRefresh() {
41+
movieId?.let { id ->
42+
if (movieId.isNullOrBlank()) return
43+
viewModelScope.launch {
44+
try {
45+
showRefreshing()
46+
_movie.value = userRepository.getMovieById(movieId = id)
47+
} catch (e: Exception) {
48+
onError(e)
49+
} finally {
50+
hideRefreshing()
51+
}
52+
}
53+
}
54+
}
55+
}

0 commit comments

Comments
 (0)