Skip to content

Commit

Permalink
Feature/v2 final (#77)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
enginebai committed Nov 13, 2021
1 parent 56df03e commit 5cb36be
Show file tree
Hide file tree
Showing 43 changed files with 407 additions and 357 deletions.
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
<h1 align="center">MovieHunt</h1>

<p align="center">
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.
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.

> The new v2 re-design is available. 🎉 Check it out now!
![MovieHunt](./art/MovieHunt.png)

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

## Architectures

Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ dependencies {
"kapt"(Dependencies.Room.annotation)

implementation(Dependencies.androidBase)
implementation(Dependencies.snapHelper)

implementation(Dependencies.okhttp)
implementation(Dependencies.okhttpLogging)
Expand Down
28 changes: 18 additions & 10 deletions app/src/main/java/com/enginebai/moviehunt/AppContext.kt
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
package com.enginebai.moviehunt

import coil.Coil
import coil.ImageLoader
import com.enginebai.base.BaseApplication
import com.enginebai.moviehunt.di.*
import org.koin.android.ext.android.get

class AppContext : BaseApplication() {

override fun defineDependencies() = listOf(
loggingModule,
gsonModule,
errorHandleModule,
networkModule,
appModule,
viewModelModule,
apiModule,
dbModule,
daoModule,
repoModule
loggingModule,
gsonModule,
errorHandleModule,
networkModule,
appModule,
viewModelModule,
apiModule,
dbModule,
daoModule,
repoModule
)

override fun onCreate() {
super.onCreate()
Coil.setImageLoader(get<ImageLoader>())
}
}
3 changes: 0 additions & 3 deletions app/src/main/java/com/enginebai/moviehunt/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ import android.widget.Toast
import com.enginebai.base.view.BaseActivity
import com.enginebai.base.view.BaseViewModel
import com.enginebai.moviehunt.data.repo.ConfigRepo
import com.enginebai.moviehunt.data.repo.MovieRepo
import com.enginebai.moviehunt.ui.home.MovieHomeFragment
import com.enginebai.moviehunt.ui.home.SplashFragment
import com.enginebai.moviehunt.utils.ExceptionHandler
import com.enginebai.moviehunt.utils.openFragment
import io.reactivex.Completable
import io.reactivex.Scheduler
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
import io.reactivex.disposables.SerialDisposable
import io.reactivex.schedulers.Schedulers
import org.koin.android.ext.android.inject
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ interface MovieDao {
FROM movie
INNER JOIN movie_list ON movie.id == movie_list.movie_id
WHERE movie_list.category = :category
ORDER BY position
"""
)
fun queryMovieListDataSource(category: MovieCategory): DataSource.Factory<Int, MovieModel>
Expand Down
18 changes: 4 additions & 14 deletions app/src/main/java/com/enginebai/moviehunt/data/local/MovieModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.PrimaryKey
import com.enginebai.moviehunt.BuildConfig
import com.enginebai.moviehunt.data.remote.Genre
import com.enginebai.moviehunt.ui.holders.MoviePortraitHolder_
import com.enginebai.moviehunt.data.remote.ImageApi
import com.enginebai.moviehunt.data.remote.ImageSize
import com.enginebai.moviehunt.ui.list.MovieCategory
import com.enginebai.moviehunt.ui.widgets.MoviePortraitHolder_
import com.enginebai.moviehunt.utils.DateTimeFormatter.format
import com.enginebai.moviehunt.utils.format
import com.enginebai.moviehunt.utils.formatHourMinutes
Expand Down Expand Up @@ -54,27 +55,16 @@ data class MovieModel(
val backdropPath: String? = null,
)

fun MovieModel.getPosterUrl(): String = "${BuildConfig.IMAGE_API_KEY}w500/${this.posterPath}"
fun MovieModel.getPosterUrlWithLargeSize(): String =
"${BuildConfig.IMAGE_API_KEY}w780/${this.posterPath}"

fun MovieModel.getPosterUrlWithOriginalSize(): String =
"${BuildConfig.IMAGE_API_KEY}original/${this.posterPath}"

fun MovieModel.displayTitle(): String = this.title ?: PLACEHOLDER
fun MovieModel.display5StarsRating(): Float = this.voteAverage?.div(2) ?: 0.0f
fun MovieModel.displayVoteCount(): String = this.voteCount?.format() ?: PLACEHOLDER

// scale from 0-10 to 0-100%
fun MovieModel.displayVotePercentage(): String = "${this.voteAverage?.times(10) ?: PLACEHOLDER}%"

fun MovieModel.displayDuration(): String = this.runtime?.formatHourMinutes() ?: PLACEHOLDER
fun MovieModel.displayReleaseDate(): String = this.releaseDate?.format() ?: PLACEHOLDER
fun MovieModel.displayOverview(): String = this.overview ?: PLACEHOLDER

fun MovieModel.toPortraitHolder(): MoviePortraitHolder_ = MoviePortraitHolder_()
.movieId(this.id)
.posterUrl(this.getPosterUrl())
.posterUrl(ImageApi.getFullUrl(this.posterPath, ImageSize.W500))
.movieName(this.displayTitle())
.rating(this.display5StarsRating())
.ratingTotalCountText(this.displayVoteCount())
Expand Down
19 changes: 19 additions & 0 deletions app/src/main/java/com/enginebai/moviehunt/data/remote/ImageApi.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package com.enginebai.moviehunt.data.remote

import com.enginebai.moviehunt.BuildConfig

object ImageApi {
fun getFullUrl(path: String?, size: ImageSize? = ImageSize.ORIGINAL) =
"${BuildConfig.IMAGE_API_KEY}${size}/${path}"
}

enum class ImageSize(val path: String) {
W500("w500"),
W780("w780"),
W45("w45"),
W185("w185"),
H632("h632"),
ORIGINAL("original");

override fun toString() = path
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,27 +3,28 @@ package com.enginebai.moviehunt.data.remote
import androidx.paging.DataSource
import androidx.paging.PageKeyedDataSource
import com.enginebai.base.utils.NetworkState
import com.enginebai.moviehunt.data.local.MovieModel
import com.enginebai.moviehunt.data.remote.MovieModelMapper.toMovieModel
import com.enginebai.moviehunt.ui.list.MovieCategory
import io.reactivex.Single
import io.reactivex.subjects.BehaviorSubject
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject

class MovieReviewsDataSource(
private val movieId: String,
abstract class ApiPageKeyedDataSource<T>(
private val initLoadState: BehaviorSubject<NetworkState>,
private val loadMoreState: BehaviorSubject<NetworkState>
) : PageKeyedDataSource<Int, Review>(), KoinComponent {
) : PageKeyedDataSource<Int, T>(), KoinComponent {

private val api: MovieApiService by inject()
private var currentPage: Int = -1

abstract fun apiFetch(page: Int): Single<TmdbApiResponse<T>>

override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, Review>
callback: LoadInitialCallback<Int, T>
) {
currentPage = 1
api.fetchMovieReviews(movieId, currentPage)
apiFetch(currentPage)
.doOnSubscribe { initLoadState.onNext(NetworkState.LOADING) }
.doOnSuccess {
it.results?.run {
Expand All @@ -39,9 +40,9 @@ class MovieReviewsDataSource(
.subscribe()
}

override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, Review>) {
override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
if (-1 == params.key || NetworkState.LOADING == loadMoreState.value) return
api.fetchMovieReviews(movieId, params.key)
apiFetch(params.key)
.doOnSubscribe { loadMoreState.onNext(NetworkState.LOADING) }
.doOnSuccess {
it.results?.run {
Expand All @@ -53,7 +54,7 @@ class MovieReviewsDataSource(
.subscribe()
}

override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, Review>) {
override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, T>) {
// we don't need this
}

Expand All @@ -69,86 +70,45 @@ class MovieReviewsDataSource(
}
}

class MovieListDataSource(
private val category: MovieCategory,
private val initLoadState: BehaviorSubject<NetworkState>,
private val loadMoreState: BehaviorSubject<NetworkState>
) : PageKeyedDataSource<Int, MovieModel>(), KoinComponent {
abstract class ApiPageKeyedDataSourceFactory<T> : DataSource.Factory<Int, T>() {
val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)

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

override fun loadInitial(
params: LoadInitialParams<Int>,
callback: LoadInitialCallback<Int, MovieModel>
) {
currentPage = 1
api.fetchMovieList(category.key, currentPage)
.doOnSubscribe { initLoadState.onNext(NetworkState.LOADING) }
.doOnSuccess {
it.results?.run {
callback.onResult(
this.mapToMovieModels(),
null,
calculateNextPage(it.totalPages)
)
}
initLoadState.onNext(NetworkState.IDLE)
}
.doOnError { initLoadState.onNext(NetworkState.ERROR) }
.subscribe()
}
class MovieReviewsDataSource(
private val movieId: String,
initLoadState: BehaviorSubject<NetworkState>,
loadMoreState: BehaviorSubject<NetworkState>
) : ApiPageKeyedDataSource<Review>(initLoadState, loadMoreState) {
private val api: MovieApiService by inject()

override fun loadAfter(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
if (-1 == params.key || NetworkState.LOADING == loadMoreState.value) return
api.fetchMovieList(category.key, params.key)
.doOnSubscribe { loadMoreState.onNext(NetworkState.LOADING) }
.doOnSuccess {
it.results?.run {
callback.onResult(this.mapToMovieModels(), calculateNextPage(it.totalPages))
}
loadMoreState.onNext(NetworkState.IDLE)
}
.doOnError { loadMoreState.onNext(NetworkState.ERROR) }
.subscribe()
}
override fun apiFetch(page: Int) = api.fetchMovieReviews(movieId, page)
}

override fun loadBefore(params: LoadParams<Int>, callback: LoadCallback<Int, MovieModel>) {
// we don't need this
}
class MovieListDataSource(
private val category: MovieCategory,
initLoadState: BehaviorSubject<NetworkState>,
loadMoreState: BehaviorSubject<NetworkState>
) : ApiPageKeyedDataSource<MovieListResponse>(initLoadState, loadMoreState) {

private fun calculateNextPage(totalPage: Int?): Int {
totalPage?.run {
currentPage = if (currentPage in 1 until totalPage) {
currentPage.plus(1)
} else {
-1
}
}
return currentPage
}
private val api: MovieApiService by inject()

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

class MovieListDataSourceFactory(private val category: MovieCategory) :
DataSource.Factory<Int, MovieModel>() {
ApiPageKeyedDataSourceFactory<MovieListResponse>() {

val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
var dataSource: MovieListDataSource? = null

override fun create(): DataSource<Int, MovieModel> {
override fun create(): DataSource<Int, MovieListResponse> {
dataSource = MovieListDataSource(category, initLoadState, loadMoreState)
return dataSource!!
}
}

class MovieReviewsDataSourceFactory(private val movieId: String) : DataSource.Factory<Int, Review>() {
val initLoadState = BehaviorSubject.createDefault(NetworkState.IDLE)
val loadMoreState = BehaviorSubject.createDefault(NetworkState.IDLE)
var dataSource: MovieReviewsDataSource? = null
class MovieReviewsDataSourceFactory(private val movieId: String) :
ApiPageKeyedDataSourceFactory<Review>() {

override fun create(): DataSource<Int, Review> {
dataSource = MovieReviewsDataSource(movieId, initLoadState, loadMoreState)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ data class Review(
return if (avatarPath?.startsWith("/http", ignoreCase = true) == true) {
avatarPath.replaceFirst("/", "")
} else {
"${BuildConfig.IMAGE_API_KEY}w185/$avatarPath"
ImageApi.getFullUrl(avatarPath, ImageSize.W185)
}
}
}
Expand All @@ -137,7 +137,7 @@ data class CastListing(
@SerializedName("profile_path")
val profilePath: String
) {
fun getAvatarFullPath() = "${BuildConfig.IMAGE_API_KEY}w185/$profilePath"
fun getAvatarFullPath() = ImageApi.getFullUrl(profilePath, ImageSize.W185)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.enginebai.moviehunt.data.repo

import android.util.Log
import androidx.paging.PagedList
import androidx.paging.RxPagedListBuilder
import com.enginebai.base.utils.Listing
Expand Down Expand Up @@ -63,7 +64,9 @@ class MovieRepoImpl : MovieRepo, KoinComponent {
.setPageSize(pageSize)
.setEnablePlaceholders(false)
.build()
val pagedList = RxPagedListBuilder(dataSourceFactory, pagedListConfig)
val pagedList = RxPagedListBuilder(dataSourceFactory.map {
it.toMovieModel()
}, pagedListConfig)
.setFetchScheduler(Schedulers.io())
.buildObservable()
return Listing(
Expand All @@ -76,6 +79,7 @@ class MovieRepoImpl : MovieRepo, KoinComponent {

override fun fetchMovieReviewPagedListing(movieId: String, pageSize: Int): Listing<Review> {
val dataSourceFactory = MovieReviewsDataSourceFactory(movieId)

val pagedListConfig = PagedList.Config.Builder()
.setPageSize(pageSize)
.setEnablePlaceholders(false)
Expand Down
Loading

0 comments on commit 5cb36be

Please sign in to comment.