diff --git a/app/build.gradle b/app/build.gradle index 0d0fa1b..2643cc6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,10 @@ android { kotlinOptions { jvmTarget = '1.8' } + + viewBinding { + enabled = true + } } dependencies { @@ -40,6 +44,8 @@ dependencies { implementation libs.material implementation libs.androidx.activity implementation libs.androidx.constraintlayout + implementation libs.androidx.recyclerview + implementation libs.swipe.refresh.layout // dagger implementation libs.dagger kapt libs.dagger.compiler @@ -47,6 +53,7 @@ dependencies { //retrofit implementation libs.retrofit implementation libs.retrofit.rx.adapter + implementation libs.converter.gson //glide implementation libs.glide @@ -57,6 +64,8 @@ dependencies { implementation libs.rx.kotlin testImplementation libs.junit + testImplementation libs.mockito.core + testImplementation libs.mockito.kotlin androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core -} \ No newline at end of file +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a18c7a..2872bf6 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -1,26 +1,29 @@ + xmlns:tools="http://schemas.android.com/tools"> - - - - + - - - - + + + + - \ No newline at end of file + + + + + + diff --git a/app/src/main/java/com/example/otchallenge/Constants.kt b/app/src/main/java/com/example/otchallenge/Constants.kt new file mode 100644 index 0000000..cb4831d --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/Constants.kt @@ -0,0 +1,3 @@ +package com.example.otchallenge + +const val BOOKS_API_KEY="KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB" diff --git a/app/src/main/java/com/example/otchallenge/MainActivity.kt b/app/src/main/java/com/example/otchallenge/MainActivity.kt index d35da32..3b4dcb1 100644 --- a/app/src/main/java/com/example/otchallenge/MainActivity.kt +++ b/app/src/main/java/com/example/otchallenge/MainActivity.kt @@ -1,22 +1,79 @@ package com.example.otchallenge import android.os.Bundle -import androidx.activity.enableEdgeToEdge +import android.view.LayoutInflater +import android.widget.Toast import androidx.appcompat.app.AppCompatActivity -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat - -class MainActivity : AppCompatActivity() { - - override fun onCreate(savedInstanceState: Bundle?) { - (application as MyApplication).appComponent.inject(this) - super.onCreate(savedInstanceState) - enableEdgeToEdge() - setContentView(R.layout.activity_main) - ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets -> - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets - } - } +import androidx.core.view.isVisible +import androidx.recyclerview.widget.LinearLayoutManager +import com.example.otchallenge.databinding.ActivityMainBinding +import com.example.otchallenge.modules.DataProvider +import com.example.otchallenge.model.BookModel +import com.example.otchallenge.modules.ImageProvider +import com.example.otchallenge.presenter.BooksPresenter +import com.example.otchallenge.presenter.BooksPresenterInterface +import com.example.otchallenge.ui.BooksAdapter +import javax.inject.Inject + +class MainActivity : AppCompatActivity(), BooksPresenterInterface { + + @Inject + lateinit var imageProvider: ImageProvider + + @Inject + lateinit var dataProvider: DataProvider + + private lateinit var binding: ActivityMainBinding + + private lateinit var presenter: BooksPresenter + + private lateinit var adapter: BooksAdapter + + override fun onCreate(savedInstanceState: Bundle?) { + (application as MyApplication).appComponent.inject(this) + super.onCreate(savedInstanceState) + binding = ActivityMainBinding.inflate(LayoutInflater.from(this)) + setContentView(binding.root) + initUi() + } + + private fun initUi() { + binding.recyclerView.layoutManager = LinearLayoutManager(binding.root.context) + adapter = BooksAdapter(imageProvider) + binding.recyclerView.adapter = adapter + binding.retryButton.setOnClickListener { + // retry to get the information + dataProvider.getBooks() + } + binding.swipeRefreshLayout.setOnRefreshListener { + // refresh the information + dataProvider.getBooks() + } + // due the strong reference between ui and presenter, presenter should live + // only in the lifecycle scope with the view + presenter = BooksPresenter(this@MainActivity) + dataProvider.booksData.observe(this@MainActivity) { + // display the information with the presenter + presenter.presentUi(it) + } + // as view has been created we can request the initial data + dataProvider.getBooks() + } + + override fun displayBooks(books: List) { + adapter.updateData(books) + binding.retryButton.isVisible = false + } + + override fun displayLoader(showLoader: Boolean) { + binding.swipeRefreshLayout.isRefreshing = showLoader + } + + override fun displayError(message: String?) { + binding.swipeRefreshLayout.isRefreshing = false + binding.retryButton.isVisible = true + message?.let { + Toast.makeText(this@MainActivity, it, Toast.LENGTH_LONG).show() + } + } } diff --git a/app/src/main/java/com/example/otchallenge/MyApplication.kt b/app/src/main/java/com/example/otchallenge/MyApplication.kt index ba66e70..e144604 100644 --- a/app/src/main/java/com/example/otchallenge/MyApplication.kt +++ b/app/src/main/java/com/example/otchallenge/MyApplication.kt @@ -3,13 +3,20 @@ package com.example.otchallenge import android.app.Application import com.example.otchallenge.di.AppComponent import com.example.otchallenge.di.DaggerAppComponent +import com.example.otchallenge.di.DataProviderModule +import com.example.otchallenge.di.ImageProviderModule +import com.example.otchallenge.di.NetworkProviderModule class MyApplication : Application() { - lateinit var appComponent: AppComponent + lateinit var appComponent: AppComponent - override fun onCreate() { - super.onCreate() - appComponent = DaggerAppComponent.builder().build() - } + override fun onCreate() { + super.onCreate() + appComponent = DaggerAppComponent.builder() + .networkProviderModule(NetworkProviderModule()) + .imageProviderModule(ImageProviderModule(this)) + .dataProviderModule(DataProviderModule()) + .build() + } } diff --git a/app/src/main/java/com/example/otchallenge/api/BooksApi.kt b/app/src/main/java/com/example/otchallenge/api/BooksApi.kt new file mode 100644 index 0000000..13a290f --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/api/BooksApi.kt @@ -0,0 +1,12 @@ +package com.example.otchallenge.api + +import com.example.otchallenge.BOOKS_API_KEY +import com.example.otchallenge.model.BooksResponseModel +import io.reactivex.Single +import retrofit2.http.GET +import retrofit2.http.Query + +interface BooksApi { + @GET("svc/books/v3/lists/current/hardcover-fiction.json?&api-key=${BOOKS_API_KEY}") + fun getBooks(@Query("offset") offset: Int = 0): Single +} diff --git a/app/src/main/java/com/example/otchallenge/di/AppComponent.kt b/app/src/main/java/com/example/otchallenge/di/AppComponent.kt index d3c83c6..2a3acf9 100644 --- a/app/src/main/java/com/example/otchallenge/di/AppComponent.kt +++ b/app/src/main/java/com/example/otchallenge/di/AppComponent.kt @@ -5,7 +5,13 @@ import dagger.Component import javax.inject.Singleton @Singleton -@Component +@Component( + modules = [ + NetworkProviderModule::class, + DataProviderModule::class, + ImageProviderModule::class + ] +) interface AppComponent { - fun inject(activity: MainActivity) + fun inject(activity: MainActivity) } diff --git a/app/src/main/java/com/example/otchallenge/di/DataProviderModule.kt b/app/src/main/java/com/example/otchallenge/di/DataProviderModule.kt new file mode 100644 index 0000000..b5fa52e --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/DataProviderModule.kt @@ -0,0 +1,17 @@ +package com.example.otchallenge.di + +import com.example.otchallenge.api.BooksApi +import com.example.otchallenge.modules.DataProvider +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class DataProviderModule { + + @Provides + @Singleton + fun provideDataProvider(api: BooksApi): DataProvider { + return DataProvider(api) + } +} diff --git a/app/src/main/java/com/example/otchallenge/di/ImageProviderModule.kt b/app/src/main/java/com/example/otchallenge/di/ImageProviderModule.kt new file mode 100644 index 0000000..485419f --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/ImageProviderModule.kt @@ -0,0 +1,15 @@ +package com.example.otchallenge.di + +import android.content.Context +import com.example.otchallenge.modules.ImageProvider +import dagger.Module +import dagger.Provides + +@Module +class ImageProviderModule(private val context: Context) { + + @Provides + fun provideImageProvider(): ImageProvider { + return ImageProvider(context) + } +} diff --git a/app/src/main/java/com/example/otchallenge/di/NetworkProviderModule.kt b/app/src/main/java/com/example/otchallenge/di/NetworkProviderModule.kt new file mode 100644 index 0000000..054f7c7 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/NetworkProviderModule.kt @@ -0,0 +1,29 @@ +package com.example.otchallenge.di + +import com.example.otchallenge.api.BooksApi +import dagger.Module +import dagger.Provides +import retrofit2.Retrofit +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import retrofit2.converter.gson.GsonConverterFactory +import javax.inject.Singleton + +@Module +class NetworkProviderModule { + + @Provides + @Singleton + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api.nytimes.com/") + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideApi(retrofit: Retrofit): BooksApi { + return retrofit.create(BooksApi::class.java) + } +} diff --git a/app/src/main/java/com/example/otchallenge/model/BookModel.kt b/app/src/main/java/com/example/otchallenge/model/BookModel.kt new file mode 100644 index 0000000..a282625 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/BookModel.kt @@ -0,0 +1,10 @@ +package com.example.otchallenge.model + +import com.google.gson.annotations.SerializedName + +data class BookModel( + val title: String? = null, + @SerializedName("book_image") + val image: String? = null, + val description: String? = null +) diff --git a/app/src/main/java/com/example/otchallenge/model/BooksData.kt b/app/src/main/java/com/example/otchallenge/model/BooksData.kt new file mode 100644 index 0000000..0898d82 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/BooksData.kt @@ -0,0 +1,9 @@ +package com.example.otchallenge.model + +data class BooksData( + val isLoading: Boolean = false, + val shouldLoadMore: Boolean = false, + val books: List = emptyList(), + val showError: Boolean = false, + val errorMessage: String? = null, +) diff --git a/app/src/main/java/com/example/otchallenge/model/BooksResponseModel.kt b/app/src/main/java/com/example/otchallenge/model/BooksResponseModel.kt new file mode 100644 index 0000000..4350a9f --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/BooksResponseModel.kt @@ -0,0 +1,11 @@ +package com.example.otchallenge.model + +import com.google.gson.annotations.SerializedName + +data class BooksResponseModel( + @SerializedName("num_results") + val numResults: Int? = 0, + val results: BooksResultModel? = null, +) + +data class BooksResultModel(val books: List) diff --git a/app/src/main/java/com/example/otchallenge/modules/DataProvider.kt b/app/src/main/java/com/example/otchallenge/modules/DataProvider.kt new file mode 100644 index 0000000..58ee105 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/modules/DataProvider.kt @@ -0,0 +1,56 @@ +package com.example.otchallenge.modules + +import androidx.lifecycle.MutableLiveData +import com.example.otchallenge.api.BooksApi +import com.example.otchallenge.model.BooksData +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers + +/** + * Data Provider class + * + * the data provider of the application, his duty is to retrieve and provide + * the data to be displayed. + * + * Note: open class for testing purposes + */ +open class DataProvider(private val api: BooksApi) { + + open val booksData: MutableLiveData by lazy { + MutableLiveData(BooksData()) + } + + open fun getBooks() { + // disposable should be disposed when view is destroyed + val disposable = api.getBooks() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .doOnSubscribe { + booksData.postValue( + booksData.value?.copy( + isLoading = true, + showError = false, + errorMessage = null, + ) + ) + } + .subscribe({ + booksData.postValue( + booksData.value?.copy( + isLoading = false, + books = it.results?.books ?: emptyList(), + showError = false, + errorMessage = null, + ) + ) + }, { + booksData.postValue( + booksData.value?.copy( + isLoading = false, + showError = true, + errorMessage = it.message, + ) + ) + }) + } +} diff --git a/app/src/main/java/com/example/otchallenge/modules/ImageProvider.kt b/app/src/main/java/com/example/otchallenge/modules/ImageProvider.kt new file mode 100644 index 0000000..5c52a0b --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/modules/ImageProvider.kt @@ -0,0 +1,27 @@ +package com.example.otchallenge.modules + +import android.content.Context +import android.widget.ImageView +import com.bumptech.glide.Glide +import com.example.otchallenge.R + +/** + * this is the image provider implementation created to decouple the image + * library from the logical code, receives teh context to share the cache and speed up load + * + * during load will display launcher as place holder, this is useful in low speed connections + * + * Note: open class for mock testing purposes + */ +open class ImageProvider(private val context: Context) { + + open fun displayImage(view: ImageView?, url: String? = null) { + if (url.isNullOrEmpty() || view == null) { + return + } + Glide.with(context) + .load(url) + .placeholder(R.drawable.ic_launcher_foreground) + .into(view) + } +} diff --git a/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt new file mode 100644 index 0000000..84982dc --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt @@ -0,0 +1,20 @@ +package com.example.otchallenge.presenter + +import com.example.otchallenge.model.BooksData + +class BooksPresenter(private val presenterInterface: BooksPresenterInterface) { + + fun presentUi(data: BooksData) { + if (data.showError) { + presenterInterface.displayError(data.errorMessage) + } else { + presenterInterface.displayLoader(data.isLoading) + if (data.books.isEmpty()) { + // ideally we should display an empty data message + presenterInterface.displayBooks(emptyList()) + } else { + presenterInterface.displayBooks(data.books) + } + } + } +} diff --git a/app/src/main/java/com/example/otchallenge/presenter/BooksPresenterInterface.kt b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenterInterface.kt new file mode 100644 index 0000000..8f98c18 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenterInterface.kt @@ -0,0 +1,17 @@ +package com.example.otchallenge.presenter + +import com.example.otchallenge.model.BookModel + +/** + * Books presenter interface + * + * interface that defines the contract between the view and the Presenter Class + */ +interface BooksPresenterInterface { + + fun displayBooks(books: List) + + fun displayLoader(showLoader: Boolean) + + fun displayError(message: String?) +} diff --git a/app/src/main/java/com/example/otchallenge/ui/BooksAdapter.kt b/app/src/main/java/com/example/otchallenge/ui/BooksAdapter.kt new file mode 100644 index 0000000..e4c61ce --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/ui/BooksAdapter.kt @@ -0,0 +1,44 @@ +package com.example.otchallenge.ui + +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.example.otchallenge.R +import com.example.otchallenge.model.BookModel +import com.example.otchallenge.modules.ImageProvider + +class BooksAdapter( + private val imageProvider: ImageProvider, +) : RecyclerView.Adapter() { + + private val books = mutableListOf() + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookVieHolder { + val layoutInflater = LayoutInflater.from(parent.context) + return BookVieHolder(layoutInflater.inflate(R.layout.book_item_view, parent, false)) + } + + override fun getItemCount() = books.count() + + override fun onBindViewHolder(holder: BookVieHolder, position: Int) { + val bookToDisplay = books[position] + holder.title?.text = bookToDisplay.title + holder.description?.text = bookToDisplay.description + imageProvider.displayImage(holder.image, bookToDisplay.image) + } + + fun updateData(newBooks: List) { + books.clear() + books.addAll(newBooks) + // we replaced all data so we have to call this even with warning + notifyDataSetChanged() + } +} + +class BookVieHolder(view: View) : RecyclerView.ViewHolder(view) { + val image: ImageView? = view.findViewById(R.id.imageView) + val title: TextView? = view.findViewById(R.id.title) + val description: TextView? = view.findViewById(R.id.description) +} diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7a2d54..1cd8bd1 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,29 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools" + android:layout_width="match_parent" + android:layout_height="match_parent" + tools:context=".MainActivity"> - + - \ No newline at end of file + + + +