Skip to content

Commit 5cb36be

Browse files
committed
Feature/v2 final (#77)
* Update README and arts * Add abstract api remote paging data source class Migrate movie list API to this abstract class Migrate movie review list API to this abstract class * Add page keyed data source factory abstract class * Set snap and padding for movie detail * Remove home category header holder epoxy group model * Integrate movie home showcase * Fix Timber and logger will not show log issue * Add image full path formatter * Setup singleton image loader, error and placeholder drawable for Coil
1 parent 56df03e commit 5cb36be

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+407
-357
lines changed

README.md

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<h1 align="center">MovieHunt</h1>
22

33
<p align="center">
4-
MovieHunt is a sample Android project using <a href="https://www.themoviedb.org/">The Movie DB</a> API based on MVVM architecture. It showcases the app development with well-designed architecture and up-to-date Android tech stacks.
4+
MovieHunt is a sample Android project using <a href="https://www.themoviedb.org/">The Movie DB</a> API based on MVVM architecture. It showcases the latest Android tech stacks with well-designed architecture and best practices.
5+
6+
> The new v2 re-design is available. 🎉 Check it out now!
57
68
![MovieHunt](./art/MovieHunt.png)
79

@@ -11,8 +13,8 @@ MovieHunt is a sample Android project using <a href="https://www.themoviedb.org/
1113
* 100% Kotlin
1214
* MVVM architecture
1315
* Reactive pattern
14-
* Android architecture components and Jetpack
15-
* Single activity
16+
* Android architecture components and Jetpack libraries
17+
* Single activity pattern
1618
* Dependency injection
1719
* CI support (Upcoming)
1820
* Testing (Upcoming)
@@ -30,12 +32,13 @@ MovieHunt is a sample Android project using <a href="https://www.themoviedb.org/
3032
* [Data Binding](https://developer.android.com/topic/libraries/data-binding) - Declarative way to bind data to UI layout.
3133
* [Navigation component](https://developer.android.com/guide/navigation) - Fragment routing handler. (Upcoming)
3234
* [WorkManager](https://developer.android.com/topic/libraries/architecture/workmanager) - Tasks scheduler in background jobs. (Upcoming)
33-
* [RxJava](https://github.com/ReactiveX/RxJava) - Asynchronous programming with observable streams.
34-
* [Epoxy](https://github.com/airbnb/epoxy) - Simplified way to build complex layout in RecyclerView.
35+
* ~[RxJava](https://github.com/ReactiveX/RxJava) - Asynchronous programming with observable streams.~ Replaced by Coroutine + Flow.
36+
* (Upcoming) [Flow](https://developer.android.com/kotlin/flow) Stream of value that returns from suspend function.
37+
* (Implementing) [Coroutine](https://developer.android.com/kotlin/coroutines) Concurrency design pattern for asynchronous programming.
38+
* ~[Epoxy](https://github.com/airbnb/epoxy) - Simplified way to build complex layout in RecyclerView.~ Replaced by Jetpack Compose.
3539
* [Coil](https://github.com/coil-kt/coil) - Image loading.
3640
* [Timber](https://github.com/JakeWharton/timber) - Extensible API for logging.
37-
* [Jetpack Compose](https://developer.android.com/jetpack/compose) - Declarative and simplified way for UI development. (Upcoming)
38-
* [Coroutines](https://developer.android.com/kotlin/coroutines) - Light-weight threads for background operations. (Upcoming)
41+
* (Implementing) [Jetpack Compose](https://developer.android.com/jetpack/compose) - Declarative and simplified way for UI development.
3942

4043
## Architectures
4144

app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ dependencies {
3737
"kapt"(Dependencies.Room.annotation)
3838

3939
implementation(Dependencies.androidBase)
40+
implementation(Dependencies.snapHelper)
4041

4142
implementation(Dependencies.okhttp)
4243
implementation(Dependencies.okhttpLogging)
Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
package com.enginebai.moviehunt
22

3+
import coil.Coil
4+
import coil.ImageLoader
35
import com.enginebai.base.BaseApplication
46
import com.enginebai.moviehunt.di.*
7+
import org.koin.android.ext.android.get
58

69
class AppContext : BaseApplication() {
710

811
override fun defineDependencies() = listOf(
9-
loggingModule,
10-
gsonModule,
11-
errorHandleModule,
12-
networkModule,
13-
appModule,
14-
viewModelModule,
15-
apiModule,
16-
dbModule,
17-
daoModule,
18-
repoModule
12+
loggingModule,
13+
gsonModule,
14+
errorHandleModule,
15+
networkModule,
16+
appModule,
17+
viewModelModule,
18+
apiModule,
19+
dbModule,
20+
daoModule,
21+
repoModule
1922
)
23+
24+
override fun onCreate() {
25+
super.onCreate()
26+
Coil.setImageLoader(get<ImageLoader>())
27+
}
2028
}

app/src/main/java/com/enginebai/moviehunt/MainActivity.kt

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,12 @@ import android.widget.Toast
77
import com.enginebai.base.view.BaseActivity
88
import com.enginebai.base.view.BaseViewModel
99
import com.enginebai.moviehunt.data.repo.ConfigRepo
10-
import com.enginebai.moviehunt.data.repo.MovieRepo
1110
import com.enginebai.moviehunt.ui.home.MovieHomeFragment
1211
import com.enginebai.moviehunt.ui.home.SplashFragment
1312
import com.enginebai.moviehunt.utils.ExceptionHandler
1413
import com.enginebai.moviehunt.utils.openFragment
1514
import io.reactivex.Completable
16-
import io.reactivex.Scheduler
1715
import io.reactivex.android.schedulers.AndroidSchedulers
18-
import io.reactivex.disposables.Disposable
1916
import io.reactivex.disposables.SerialDisposable
2017
import io.reactivex.schedulers.Schedulers
2118
import org.koin.android.ext.android.inject

app/src/main/java/com/enginebai/moviehunt/data/local/MovieDao.kt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,6 @@ interface MovieDao {
6565
FROM movie
6666
INNER JOIN movie_list ON movie.id == movie_list.movie_id
6767
WHERE movie_list.category = :category
68-
ORDER BY position
6968
"""
7069
)
7170
fun queryMovieListDataSource(category: MovieCategory): DataSource.Factory<Int, MovieModel>

app/src/main/java/com/enginebai/moviehunt/data/local/MovieModel.kt

Lines changed: 4 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@ import androidx.room.ColumnInfo
44
import androidx.room.Entity
55
import androidx.room.ForeignKey
66
import androidx.room.PrimaryKey
7-
import com.enginebai.moviehunt.BuildConfig
87
import com.enginebai.moviehunt.data.remote.Genre
9-
import com.enginebai.moviehunt.ui.holders.MoviePortraitHolder_
8+
import com.enginebai.moviehunt.data.remote.ImageApi
9+
import com.enginebai.moviehunt.data.remote.ImageSize
1010
import com.enginebai.moviehunt.ui.list.MovieCategory
11+
import com.enginebai.moviehunt.ui.widgets.MoviePortraitHolder_
1112
import com.enginebai.moviehunt.utils.DateTimeFormatter.format
1213
import com.enginebai.moviehunt.utils.format
1314
import com.enginebai.moviehunt.utils.formatHourMinutes
@@ -54,27 +55,16 @@ data class MovieModel(
5455
val backdropPath: String? = null,
5556
)
5657

57-
fun MovieModel.getPosterUrl(): String = "${BuildConfig.IMAGE_API_KEY}w500/${this.posterPath}"
58-
fun MovieModel.getPosterUrlWithLargeSize(): String =
59-
"${BuildConfig.IMAGE_API_KEY}w780/${this.posterPath}"
60-
61-
fun MovieModel.getPosterUrlWithOriginalSize(): String =
62-
"${BuildConfig.IMAGE_API_KEY}original/${this.posterPath}"
63-
6458
fun MovieModel.displayTitle(): String = this.title ?: PLACEHOLDER
6559
fun MovieModel.display5StarsRating(): Float = this.voteAverage?.div(2) ?: 0.0f
6660
fun MovieModel.displayVoteCount(): String = this.voteCount?.format() ?: PLACEHOLDER
67-
68-
// scale from 0-10 to 0-100%
69-
fun MovieModel.displayVotePercentage(): String = "${this.voteAverage?.times(10) ?: PLACEHOLDER}%"
70-
7161
fun MovieModel.displayDuration(): String = this.runtime?.formatHourMinutes() ?: PLACEHOLDER
7262
fun MovieModel.displayReleaseDate(): String = this.releaseDate?.format() ?: PLACEHOLDER
7363
fun MovieModel.displayOverview(): String = this.overview ?: PLACEHOLDER
7464

7565
fun MovieModel.toPortraitHolder(): MoviePortraitHolder_ = MoviePortraitHolder_()
7666
.movieId(this.id)
77-
.posterUrl(this.getPosterUrl())
67+
.posterUrl(ImageApi.getFullUrl(this.posterPath, ImageSize.W500))
7868
.movieName(this.displayTitle())
7969
.rating(this.display5StarsRating())
8070
.ratingTotalCountText(this.displayVoteCount())
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.enginebai.moviehunt.data.remote
2+
3+
import com.enginebai.moviehunt.BuildConfig
4+
5+
object ImageApi {
6+
fun getFullUrl(path: String?, size: ImageSize? = ImageSize.ORIGINAL) =
7+
"${BuildConfig.IMAGE_API_KEY}${size}/${path}"
8+
}
9+
10+
enum class ImageSize(val path: String) {
11+
W500("w500"),
12+
W780("w780"),
13+
W45("w45"),
14+
W185("w185"),
15+
H632("h632"),
16+
ORIGINAL("original");
17+
18+
override fun toString() = path
19+
}

app/src/main/java/com/enginebai/moviehunt/data/remote/MovieListDataSource.kt

Lines changed: 35 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,28 @@ package com.enginebai.moviehunt.data.remote
33
import androidx.paging.DataSource
44
import androidx.paging.PageKeyedDataSource
55
import com.enginebai.base.utils.NetworkState
6-
import com.enginebai.moviehunt.data.local.MovieModel
7-
import com.enginebai.moviehunt.data.remote.MovieModelMapper.toMovieModel
86
import com.enginebai.moviehunt.ui.list.MovieCategory
7+
import io.reactivex.Single
98
import io.reactivex.subjects.BehaviorSubject
109
import org.koin.core.component.KoinComponent
1110
import org.koin.core.component.inject
1211

13-
class MovieReviewsDataSource(
14-
private val movieId: String,
12+
abstract class ApiPageKeyedDataSource<T>(
1513
private val initLoadState: BehaviorSubject<NetworkState>,
1614
private val loadMoreState: BehaviorSubject<NetworkState>
17-
) : PageKeyedDataSource<Int, Review>(), KoinComponent {
15+
) : PageKeyedDataSource<Int, T>(), KoinComponent {
16+
1817
private val api: MovieApiService by inject()
1918
private var currentPage: Int = -1
2019

20+
abstract fun apiFetch(page: Int): Single<TmdbApiResponse<T>>
21+
2122
override fun loadInitial(
2223
params: LoadInitialParams<Int>,
23-
callback: LoadInitialCallback<Int, Review>
24+
callback: LoadInitialCallback<Int, T>
2425
) {
2526
currentPage = 1
26-
api.fetchMovieReviews(movieId, currentPage)
27+
apiFetch(currentPage)
2728
.doOnSubscribe { initLoadState.onNext(NetworkState.LOADING) }
2829
.doOnSuccess {
2930
it.results?.run {
@@ -39,9 +40,9 @@ class MovieReviewsDataSource(
3940
.subscribe()
4041
}
4142

42-
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Review>) {
43+
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
4344
if (-1 == params.key || NetworkState.LOADING == loadMoreState.value) return
44-
api.fetchMovieReviews(movieId, params.key)
45+
apiFetch(params.key)
4546
.doOnSubscribe { loadMoreState.onNext(NetworkState.LOADING) }
4647
.doOnSuccess {
4748
it.results?.run {
@@ -53,7 +54,7 @@ class MovieReviewsDataSource(
5354
.subscribe()
5455
}
5556

56-
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Review>) {
57+
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
5758
// we don't need this
5859
}
5960

@@ -69,86 +70,45 @@ class MovieReviewsDataSource(
6970
}
7071
}
7172

72-
class MovieListDataSource(
73-
private val category: MovieCategory,
74-
private val initLoadState: BehaviorSubject<NetworkState>,
75-
private val loadMoreState: BehaviorSubject<NetworkState>
76-
) : PageKeyedDataSource<Int, MovieModel>(), KoinComponent {
73+
abstract class ApiPageKeyedDataSourceFactory<T> : DataSource.Factory<Int, T>() {
74+
val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
75+
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
7776

78-
private val api: MovieApiService by inject()
79-
private var currentPage: Int = -1
77+
var dataSource: DataSource<Int, T>? = null
78+
}
8079

81-
override fun loadInitial(
82-
params: LoadInitialParams<Int>,
83-
callback: LoadInitialCallback<Int, MovieModel>
84-
) {
85-
currentPage = 1
86-
api.fetchMovieList(category.key, currentPage)
87-
.doOnSubscribe { initLoadState.onNext(NetworkState.LOADING) }
88-
.doOnSuccess {
89-
it.results?.run {
90-
callback.onResult(
91-
this.mapToMovieModels(),
92-
null,
93-
calculateNextPage(it.totalPages)
94-
)
95-
}
96-
initLoadState.onNext(NetworkState.IDLE)
97-
}
98-
.doOnError { initLoadState.onNext(NetworkState.ERROR) }
99-
.subscribe()
100-
}
80+
class MovieReviewsDataSource(
81+
private val movieId: String,
82+
initLoadState: BehaviorSubject<NetworkState>,
83+
loadMoreState: BehaviorSubject<NetworkState>
84+
) : ApiPageKeyedDataSource<Review>(initLoadState, loadMoreState) {
85+
private val api: MovieApiService by inject()
10186

102-
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
103-
if (-1 == params.key || NetworkState.LOADING == loadMoreState.value) return
104-
api.fetchMovieList(category.key, params.key)
105-
.doOnSubscribe { loadMoreState.onNext(NetworkState.LOADING) }
106-
.doOnSuccess {
107-
it.results?.run {
108-
callback.onResult(this.mapToMovieModels(), calculateNextPage(it.totalPages))
109-
}
110-
loadMoreState.onNext(NetworkState.IDLE)
111-
}
112-
.doOnError { loadMoreState.onNext(NetworkState.ERROR) }
113-
.subscribe()
114-
}
87+
override fun apiFetch(page: Int) = api.fetchMovieReviews(movieId, page)
88+
}
11589

116-
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
117-
// we don't need this
118-
}
90+
class MovieListDataSource(
91+
private val category: MovieCategory,
92+
initLoadState: BehaviorSubject<NetworkState>,
93+
loadMoreState: BehaviorSubject<NetworkState>
94+
) : ApiPageKeyedDataSource<MovieListResponse>(initLoadState, loadMoreState) {
11995

120-
private fun calculateNextPage(totalPage: Int?): Int {
121-
totalPage?.run {
122-
currentPage = if (currentPage in 1 until totalPage) {
123-
currentPage.plus(1)
124-
} else {
125-
-1
126-
}
127-
}
128-
return currentPage
129-
}
96+
private val api: MovieApiService by inject()
13097

131-
private fun List<MovieListResponse>.mapToMovieModels(): List<MovieModel> =
132-
this.map { it.toMovieModel() }
98+
override fun apiFetch(page: Int) = api.fetchMovieList(category.key, page)
13399
}
134100

135101
class MovieListDataSourceFactory(private val category: MovieCategory) :
136-
DataSource.Factory<Int, MovieModel>() {
102+
ApiPageKeyedDataSourceFactory<MovieListResponse>() {
137103

138-
val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
139-
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
140-
var dataSource: MovieListDataSource? = null
141-
142-
override fun create(): DataSource<Int, MovieModel> {
104+
override fun create(): DataSource<Int, MovieListResponse> {
143105
dataSource = MovieListDataSource(category, initLoadState, loadMoreState)
144106
return dataSource!!
145107
}
146108
}
147109

148-
class MovieReviewsDataSourceFactory(private val movieId: String) : DataSource.Factory<Int, Review>() {
149-
val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
150-
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
151-
var dataSource: MovieReviewsDataSource? = null
110+
class MovieReviewsDataSourceFactory(private val movieId: String) :
111+
ApiPageKeyedDataSourceFactory<Review>() {
152112

153113
override fun create(): DataSource<Int, Review> {
154114
dataSource = MovieReviewsDataSource(movieId, initLoadState, loadMoreState)

app/src/main/java/com/enginebai/moviehunt/data/remote/MovieResponse.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ data class Review(
117117
return if (avatarPath?.startsWith("/http", ignoreCase = true) == true) {
118118
avatarPath.replaceFirst("/", "")
119119
} else {
120-
"${BuildConfig.IMAGE_API_KEY}w185/$avatarPath"
120+
ImageApi.getFullUrl(avatarPath, ImageSize.W185)
121121
}
122122
}
123123
}
@@ -137,7 +137,7 @@ data class CastListing(
137137
@SerializedName("profile_path")
138138
val profilePath: String
139139
) {
140-
fun getAvatarFullPath() = "${BuildConfig.IMAGE_API_KEY}w185/$profilePath"
140+
fun getAvatarFullPath() = ImageApi.getFullUrl(profilePath, ImageSize.W185)
141141
}
142142
}
143143

app/src/main/java/com/enginebai/moviehunt/data/repo/MovieRepo.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.enginebai.moviehunt.data.repo
22

3+
import android.util.Log
34
import androidx.paging.PagedList
45
import androidx.paging.RxPagedListBuilder
56
import com.enginebai.base.utils.Listing
@@ -63,7 +64,9 @@ class MovieRepoImpl : MovieRepo, KoinComponent {
6364
.setPageSize(pageSize)
6465
.setEnablePlaceholders(false)
6566
.build()
66-
val pagedList = RxPagedListBuilder(dataSourceFactory, pagedListConfig)
67+
val pagedList = RxPagedListBuilder(dataSourceFactory.map {
68+
it.toMovieModel()
69+
}, pagedListConfig)
6770
.setFetchScheduler(Schedulers.io())
6871
.buildObservable()
6972
return Listing(
@@ -76,6 +79,7 @@ class MovieRepoImpl : MovieRepo, KoinComponent {
7679

7780
override fun fetchMovieReviewPagedListing(movieId: String, pageSize: Int): Listing<Review> {
7881
val dataSourceFactory = MovieReviewsDataSourceFactory(movieId)
82+
7983
val pagedListConfig = PagedList.Config.Builder()
8084
.setPageSize(pageSize)
8185
.setEnablePlaceholders(false)

0 commit comments

Comments
 (0)