diff --git a/.gitignore b/.gitignore
index 5bd175f..82c3310 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,16 +1,35 @@
-*.iml
-.gradle
-/local.properties
-/.idea/caches
-/.idea/libraries
-/.idea/modules.xml
-/.idea/workspace.xml
-/.idea/navEditor.xml
-/.idea/assetWizardSettings.xml
-.DS_Store
-/build
-/captures
-.externalNativeBuild
-.cxx
+# Gradle files
+.gradle/
+build/
+
+# Local configuration file (sdk path, etc)
local.properties
-.idea
+
+# Log/OS Files
+*.log
+
+# Android Studio generated files and folders
+captures/
+.externalNativeBuild/
+.cxx/
+*.apk
+output.json
+
+# IntelliJ
+*.iml
+.idea/
+misc.xml
+deploymentTargetDropDown.xml
+render.experimental.xml
+
+# Keystore files
+*.jks
+*.keystore
+
+# Google Services (e.g. APIs or Firebase)
+google-services.json
+
+# Android Profiling
+*.hprof
+
+**/ApiKeys.kt
\ No newline at end of file
diff --git a/README.md b/README.md
index 235ec76..1843581 100644
--- a/README.md
+++ b/README.md
@@ -22,3 +22,71 @@ Using this endpoint, show a list of these items, with each row displaying at lea
Bonus Points:
- Unit tests
+
+---
+
+### Project description
+
+This is an approach to the requested requirement of an application that shows the list of elements (books).
+The general idea is to have an Android application with three screens, a splash screen, a screen with the list of elements and a last screen with the details of each element.
+The app uses a clean architecture with MVP (Model-View-Presenter) design pattern and demonstrates various technologies including Retrofit, Dagger, Room, RxJava, and Navigation Architecture Component.
+
+#### Architecture
+##### Clean Architecture Layers
+1. Presentation Layer
+ - **View (Fragment):** Displays data and handles user interactions.
+ - **Presenter:** Contains the presentation logic and communicates with the Use Cases to fetch data.
+
+2. Domain Layer
+ - **Use Cases:** Encapsulate the application's business logic. Example: GetBooksUseCase, GetBookDetailsUseCase.
+ - **Models:** Business models used within the application logic.
+
+3. Data Layer
+ - **Repositories**: Responsible for fetching data from data sources (API, database) and converting it to domain models.
+ - **Data Sources**: Include API service interfaces and DAOs for database access.
+ - **Mappers**: Convert data models to domain models and vice versa.
+
+##### MVP Design Pattern
+
+1. **Model**: Represents the data and business logic.
+2. **View**: Displays data and routes user commands to the Presenter.
+3. **Presenter**: Retrieves data from the Model, applies presentation logic, and updates the View.
+
+#### Technologies Used
+
+- **Kotlin**: Programming language for Android development.
+- **Retrofit**: For making network requests to the New York Times Books API.
+- **Dagger**: For dependency injection.
+- **Room**: For local database storage.
+- **RxJava**: For handling asynchronous operations.
+- **Glide**: For image loading and caching.
+- **JUnit & Mockito**: For unit testing.
+- **Navigation Architecture Component**: For managing navigation and fragment transitions.
+
+#### Project Structure
+
+├───data
+│ ├───api
+│ ├───database
+│ ├───di
+│ ├───mapper
+│ ├───model
+│ └───repository
+├───di
+├───domain
+│ ├───executor
+│ ├───mapper
+│ ├───model
+│ ├───repository
+│ └───usecase
+├───presentation
+│ ├───model
+│ ├───presenter
+│ └───view
+└───utils
+
+#### Known Issues
+There is an unresolved bug where the loadBookDetails method in BookDetailPresenter does not always reach the subscribe block. This might be related to threading issues or improper disposal of RxJava subscriptions. This bug prevents the detail information from being painted in the book detail fragment.
+Further debugging is required to pinpoint the exact cause and resolve the issue.
+
+
diff --git a/app/build.gradle b/app/build.gradle
index 0d0fa1b..0986695 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -2,6 +2,7 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.jetbrains.kotlin.android)
alias(libs.plugins.kapt)
+ alias(libs.plugins.navigation.safeargs)
}
android {
@@ -19,7 +20,17 @@ android {
}
buildTypes {
+
+ debug {
+ //This information should not be hardcoded. CI\/CD override and manage.
+ buildConfigField "String", "NYT_API_KEY", "\"KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB\""
+ buildConfigField "String", "NYT_API_URL", "\"https://api.nytimes.com/svc/books/v3/\""
+ buildConfigField "String", "OFFSET", "\"0\""
+ }
release {
+ buildConfigField "String", "NYT_API_KEY", "\"KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB\""
+ buildConfigField "String", "NYT_API_URL", "\"https://api.nytimes.com/svc/books/v3/\""
+ buildConfigField "String", "OFFSET", "\"0\""
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
@@ -31,32 +42,58 @@ android {
kotlinOptions {
jvmTarget = '1.8'
}
+
+ buildFeatures {
+ buildConfig = true
+ }
}
dependencies {
-
implementation libs.androidx.core.ktx
implementation libs.androidx.appcompat
implementation libs.material
implementation libs.androidx.activity
implementation libs.androidx.constraintlayout
- // dagger
+ implementation libs.androidx.swiperefreshlayout
+
+ //Dagger
implementation libs.dagger
+ implementation libs.dagger.android
kapt libs.dagger.compiler
- //retrofit
+ //Retrofit
implementation libs.retrofit
+ implementation libs.retrofit.gson
implementation libs.retrofit.rx.adapter
- //glide
+ //Glide
implementation libs.glide
+ kapt libs.glide.compiler
- //reactive x
+ //Reactive x
implementation libs.rx.android
implementation libs.rx.java
implementation libs.rx.kotlin
+ //Room database
+ implementation libs.androidx.room
+ kapt libs.androidx.room.compiler
+ implementation libs.androidx.room.rxjava
+
+ //Navigation Architecture Component
+ implementation libs.androidx.navigation
+ implementation libs.androidx.navigation.ui
+
+ //Testing
testImplementation libs.junit
+ testImplementation libs.mockito.core
+ testImplementation libs.mockito.inline
+ testImplementation libs.powermock.module
+ testImplementation libs.powermock.api
androidTestImplementation libs.androidx.junit
androidTestImplementation libs.androidx.espresso.core
+}
+
+kapt {
+ correctErrorTypes = true
}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8a18c7a..695d9a2 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,26 +1,30 @@
+ xmlns:tools="http://schemas.android.com/tools">
-
-
-
-
+
+
-
-
-
-
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/MainActivity.kt b/app/src/main/java/com/example/otchallenge/MainActivity.kt
deleted file mode 100644
index d35da32..0000000
--- a/app/src/main/java/com/example/otchallenge/MainActivity.kt
+++ /dev/null
@@ -1,22 +0,0 @@
-package com.example.otchallenge
-
-import android.os.Bundle
-import androidx.activity.enableEdgeToEdge
-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
- }
- }
-}
diff --git a/app/src/main/java/com/example/otchallenge/MyApplication.kt b/app/src/main/java/com/example/otchallenge/MyApplication.kt
index ba66e70..c634737 100644
--- a/app/src/main/java/com/example/otchallenge/MyApplication.kt
+++ b/app/src/main/java/com/example/otchallenge/MyApplication.kt
@@ -2,14 +2,27 @@ package com.example.otchallenge
import android.app.Application
import com.example.otchallenge.di.AppComponent
+import com.example.otchallenge.di.AppModule
import com.example.otchallenge.di.DaggerAppComponent
class MyApplication : Application() {
- lateinit var appComponent: AppComponent
- override fun onCreate() {
- super.onCreate()
- appComponent = DaggerAppComponent.builder().build()
- }
+ lateinit var appComponent: AppComponent
+
+ override fun onCreate() {
+ super.onCreate()
+ appComponent = DaggerAppComponent.factory()
+ .create(AppModule(this))
+ appComponent.inject(this)
+ /*appComponent = DaggerAppComponent.builder()
+ .appModule(AppModule(this))
+ .networkModule(NetworkModule())
+ //.repositoryModule(RepositoryModule())
+ .databaseModule(DatabaseModule())
+ .presenterModule(PresenterModule())
+ .useCaseModule(UseCaseModule())
+ .build()
+ appComponent.inject(this)*/
+ }
}
diff --git a/app/src/main/java/com/example/otchallenge/data/api/BooksService.kt b/app/src/main/java/com/example/otchallenge/data/api/BooksService.kt
new file mode 100644
index 0000000..42e79e8
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/api/BooksService.kt
@@ -0,0 +1,15 @@
+package com.example.otchallenge.data.api
+
+import com.example.otchallenge.BuildConfig
+import com.example.otchallenge.data.model.OverviewResponse
+import io.reactivex.Single
+import retrofit2.Response
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+interface BooksService {
+ @GET("lists/current/hardcover-fiction.json")
+ fun getBooks(@Query("api-key") apiKey: String = BuildConfig.NYT_API_KEY):
+ Single>
+
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/database/BookDao.kt b/app/src/main/java/com/example/otchallenge/data/database/BookDao.kt
new file mode 100644
index 0000000..069194a
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/database/BookDao.kt
@@ -0,0 +1,20 @@
+package com.example.otchallenge.data.database
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.reactivex.Completable
+import io.reactivex.Single
+
+@Dao
+interface BookDao {
+ @Query("SELECT id, title, description, bookImage FROM books")
+ fun getBooks(): Single>
+
+ @Query("SELECT * FROM books WHERE id = :bookId")
+ fun getBookById(bookId: Int): Single
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ fun insertBooks(books: List): Completable
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/database/BookDataBase.kt b/app/src/main/java/com/example/otchallenge/data/database/BookDataBase.kt
new file mode 100644
index 0000000..3b2e141
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/database/BookDataBase.kt
@@ -0,0 +1,9 @@
+package com.example.otchallenge.data.database
+
+import androidx.room.Database
+import androidx.room.RoomDatabase
+
+@Database(entities = [BookEntity::class], version = 1)
+abstract class BookDatabase : RoomDatabase() {
+ abstract fun bookDao(): BookDao
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/database/BookEntity.kt b/app/src/main/java/com/example/otchallenge/data/database/BookEntity.kt
new file mode 100644
index 0000000..49365d6
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/database/BookEntity.kt
@@ -0,0 +1,27 @@
+package com.example.otchallenge.data.database
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "books", indices = [Index(value = ["primaryIsbn13"], unique = true)])
+data class BookEntity(
+ @PrimaryKey(autoGenerate = true) val id: Int = 0,
+ val rank: Int,
+ val rankLastWeek: Int,
+ val weeksOnList: Int,
+ val primaryIsbn10: String,
+ val primaryIsbn13: String,
+ val publisher: String,
+ val description: String,
+ val price: String,
+ val title: String,
+ val author: String,
+ val contributor: String,
+ val bookImage: String,
+ val bookImageWidth: Int,
+ val bookImageHeight: Int,
+ val amazonProductUrl: String,
+ val ageGroup: String,
+ val bookUri: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/database/BookSummaryEntity.kt b/app/src/main/java/com/example/otchallenge/data/database/BookSummaryEntity.kt
new file mode 100644
index 0000000..d655da5
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/database/BookSummaryEntity.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.data.database
+
+data class BookSummaryEntity(
+ val id: Int,
+ val title: String,
+ val description: String,
+ val bookImage: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/di/DatabaseModule.kt b/app/src/main/java/com/example/otchallenge/data/di/DatabaseModule.kt
new file mode 100644
index 0000000..f952421
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/di/DatabaseModule.kt
@@ -0,0 +1,27 @@
+package com.example.otchallenge.data.di
+
+import android.app.Application
+import androidx.room.Room
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.database.BookDatabase
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class DatabaseModule {
+
+ @Provides
+ @Singleton
+ fun provideDatabase(application: Application): BookDatabase {
+ return Room.databaseBuilder(application, BookDatabase::class.java, "books.db")
+ .fallbackToDestructiveMigration()
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideBookDao(database: BookDatabase): BookDao {
+ return database.bookDao()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/di/NetworkModule.kt b/app/src/main/java/com/example/otchallenge/data/di/NetworkModule.kt
new file mode 100644
index 0000000..52ecfb3
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/di/NetworkModule.kt
@@ -0,0 +1,48 @@
+package com.example.otchallenge.data.di
+
+import com.example.otchallenge.BuildConfig
+import com.example.otchallenge.data.api.BooksService
+import com.example.otchallenge.utils.NetworkInterceptor
+import com.example.otchallenge.utils.OffsetInterceptor
+import dagger.Module
+import dagger.Provides
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
+import retrofit2.converter.gson.GsonConverterFactory
+import javax.inject.Singleton
+
+@Module
+class NetworkModule {
+
+ @Provides
+ @Singleton
+ fun provideOffsetInterceptor(): OffsetInterceptor {
+ return OffsetInterceptor()
+ }
+
+ @Provides
+ @Singleton
+ fun provideRetrofit(
+ networkInterceptor: NetworkInterceptor,
+ offsetInterceptor: OffsetInterceptor
+ ): Retrofit {
+ val client = OkHttpClient.Builder()
+ .addInterceptor(networkInterceptor)
+ .addInterceptor(offsetInterceptor)
+ .build()
+
+ return Retrofit.Builder()
+ .baseUrl(BuildConfig.NYT_API_URL)
+ .client(client)
+ .addConverterFactory(GsonConverterFactory.create())
+ .addCallAdapterFactory(RxJava2CallAdapterFactory.create())
+ .build()
+ }
+
+ @Provides
+ @Singleton
+ fun provideBookService(retrofit: Retrofit): BooksService {
+ return retrofit.create(BooksService::class.java)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/di/RepositoryModule.kt b/app/src/main/java/com/example/otchallenge/data/di/RepositoryModule.kt
new file mode 100644
index 0000000..682cc19
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/di/RepositoryModule.kt
@@ -0,0 +1,31 @@
+package com.example.otchallenge.data.di
+
+import com.example.otchallenge.data.api.BooksService
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.repository.BookDetailsRepositoryImpl
+import com.example.otchallenge.data.repository.BookListRepositoryImpl
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import com.example.otchallenge.domain.repository.BookListRepository
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class RepositoryModule {
+ @Provides
+ @Singleton
+ fun provideBookListRepository(
+ bookService: BooksService,
+ bookDao: BookDao
+ ): BookListRepository {
+ return BookListRepositoryImpl(bookService, bookDao)
+ }
+
+ @Provides
+ @Singleton
+ fun provideBookDetailsRepository(
+ bookDao: BookDao
+ ): BookDetailsRepository {
+ return BookDetailsRepositoryImpl(bookDao)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/mapper/BookApiMapper.kt b/app/src/main/java/com/example/otchallenge/data/mapper/BookApiMapper.kt
new file mode 100644
index 0000000..176ba9d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/mapper/BookApiMapper.kt
@@ -0,0 +1,29 @@
+package com.example.otchallenge.data.mapper
+
+import com.example.otchallenge.data.database.BookEntity
+import com.example.otchallenge.data.model.BookApi
+import com.example.otchallenge.utils.Transform
+
+class BookApiMapper : Transform() {
+ override fun transform(value: BookApi): BookEntity {
+ return BookEntity(
+ title = value.title,
+ author = value.author,
+ description = value.description,
+ bookImage = value.bookImage,
+ primaryIsbn10 = value.primaryIsbn10,
+ primaryIsbn13 = value.primaryIsbn13,
+ publisher = value.publisher,
+ price = value.price,
+ rank = value.rank,
+ rankLastWeek = value.rankLastWeek,
+ weeksOnList = value.weeksOnList,
+ contributor = value.contributor,
+ bookImageWidth = value.bookImageWidth,
+ bookImageHeight = value.bookImageHeight,
+ amazonProductUrl = value.amazonProductUrl,
+ ageGroup = value.ageGroup,
+ bookUri = value.bookUri.toString()
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/model/BookApi.kt b/app/src/main/java/com/example/otchallenge/data/model/BookApi.kt
new file mode 100644
index 0000000..0832d11
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/BookApi.kt
@@ -0,0 +1,30 @@
+package com.example.otchallenge.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class BookApi(
+ val rank: Int,
+ @SerializedName("rank_last_week") val rankLastWeek: Int,
+ @SerializedName("weeks_on_list") val weeksOnList: Int,
+ val asterisk: Int,
+ val dagger: Int,
+ @SerializedName("primary_isbn10") val primaryIsbn10: String,
+ @SerializedName("primary_isbn13") val primaryIsbn13: String,
+ val publisher: String,
+ val description: String,
+ val price: String,
+ val title: String,
+ val author: String,
+ val contributor: String,
+ @SerializedName("contributor_note") val contributorNote: String,
+ @SerializedName("book_image") val bookImage: String,
+ @SerializedName("book_image_width") val bookImageWidth: Int,
+ @SerializedName("book_image_height") val bookImageHeight: Int,
+ @SerializedName("amazon_product_url") val amazonProductUrl: String,
+ @SerializedName("age_group") val ageGroup: String,
+ @SerializedName("book_review_link") val bookReviewLink: String,
+ @SerializedName("first_chapter_link") val firstChapterLink: String,
+ @SerializedName("sunday_review_link") val sundayReviewLink: String,
+ @SerializedName("article_chapter_link") val articleChapterLink: String,
+ val bookUri: String? = ""
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/model/BuyLink.kt b/app/src/main/java/com/example/otchallenge/data/model/BuyLink.kt
new file mode 100644
index 0000000..f96264c
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/BuyLink.kt
@@ -0,0 +1,6 @@
+package com.example.otchallenge.data.model
+
+data class BuyLink(
+ val name: String,
+ val url: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/model/Correction.kt b/app/src/main/java/com/example/otchallenge/data/model/Correction.kt
new file mode 100644
index 0000000..2e75040
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/Correction.kt
@@ -0,0 +1,6 @@
+package com.example.otchallenge.data.model
+
+data class Correction(
+ val article: String?,
+ val original: String?
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/model/Isbn.kt b/app/src/main/java/com/example/otchallenge/data/model/Isbn.kt
new file mode 100644
index 0000000..9c8a9d2
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/Isbn.kt
@@ -0,0 +1,6 @@
+package com.example.otchallenge.data.model
+
+data class Isbn(
+ val isbn10: String,
+ val isbn13: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/model/OverviewResponse.kt b/app/src/main/java/com/example/otchallenge/data/model/OverviewResponse.kt
new file mode 100644
index 0000000..c9cf90e
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/OverviewResponse.kt
@@ -0,0 +1,10 @@
+package com.example.otchallenge.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class OverviewResponse(
+ val status: String,
+ val copyright: String,
+ @SerializedName("num_results") val numResults: Int,
+ val results: Results
+)
diff --git a/app/src/main/java/com/example/otchallenge/data/model/Results.kt b/app/src/main/java/com/example/otchallenge/data/model/Results.kt
new file mode 100644
index 0000000..aa9194f
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/model/Results.kt
@@ -0,0 +1,18 @@
+package com.example.otchallenge.data.model
+
+import com.google.gson.annotations.SerializedName
+
+data class Results(
+ @SerializedName("list_name") val listName: String,
+ @SerializedName("list_name_encoded") val listNameEncoded: String,
+ @SerializedName("bestsellers_date") val bestsellersDate: String,
+ @SerializedName("published_date") val publishedDate: String,
+ @SerializedName("published_date_description") val publishedDateDescription: String,
+ @SerializedName("next_published_date") val nextPublishedDate: String,
+ @SerializedName("previous_published_date") val previousPublishedDate: String,
+ @SerializedName("display_name") val displayName: String,
+ @SerializedName("normal_list_ends_at") val normalListEndsAt: Int,
+ val updated: String,
+ val books: List,
+ val corrections: List?
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImpl.kt b/app/src/main/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImpl.kt
new file mode 100644
index 0000000..23ecbc0
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImpl.kt
@@ -0,0 +1,23 @@
+package com.example.otchallenge.data.repository
+
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.domain.mapper.BookEntityMapper
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import io.reactivex.Single
+import javax.inject.Inject
+
+class BookDetailsRepositoryImpl @Inject constructor(
+ private val bookDao: BookDao
+) : BookDetailsRepository {
+
+ private val mapper = BookEntityMapper()
+
+ override fun loadBookDetails(id: Int): Single {
+ return bookDao.getBookById(id)
+ .map { entity ->
+ val domain = mapper.transform(entity)
+ domain
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/data/repository/BookListRepositoryImpl.kt b/app/src/main/java/com/example/otchallenge/data/repository/BookListRepositoryImpl.kt
new file mode 100644
index 0000000..7c6695d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/data/repository/BookListRepositoryImpl.kt
@@ -0,0 +1,53 @@
+package com.example.otchallenge.data.repository
+
+import com.example.otchallenge.data.api.BooksService
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.mapper.BookApiMapper
+import com.example.otchallenge.domain.mapper.BookSummaryEntityMapper
+import com.example.otchallenge.domain.model.BookSummary
+import com.example.otchallenge.domain.repository.BookListRepository
+import io.reactivex.Single
+import retrofit2.HttpException
+import javax.inject.Inject
+
+class BookListRepositoryImpl @Inject constructor(
+ private val bookService: BooksService,
+ private val bookDao: BookDao
+) : BookListRepository {
+
+ private val apiMapper = BookApiMapper()
+ private val mapper = BookSummaryEntityMapper()
+
+ override fun getBooks(): Single> {
+ return bookService.getBooks()
+ .flatMap { response ->
+ if (response.isSuccessful) {
+ val books = response.body()?.results?.books?.map { bookApi ->
+ apiMapper.transform(bookApi)
+ } ?: emptyList()
+
+ Single.fromCallable {
+ bookDao.insertBooks(books)
+ }.flatMap {
+ bookDao.getBooks().map { booksWithIds ->
+ booksWithIds.map {
+ mapper.reverseTransform(it)
+ }
+ }
+ }
+ } else {
+ Single.error(HttpException(response))
+ }
+ }.onErrorResumeNext { throwable ->
+ bookDao.getBooks()
+ .map { summaries ->
+ summaries.map {
+ mapper.reverseTransform(it)
+ }
+ }
+ .onErrorResumeNext {
+ Single.error(throwable)
+ }
+ }
+ }
+}
\ No newline at end of file
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..61e2ab3 100644
--- a/app/src/main/java/com/example/otchallenge/di/AppComponent.kt
+++ b/app/src/main/java/com/example/otchallenge/di/AppComponent.kt
@@ -1,11 +1,36 @@
package com.example.otchallenge.di
-import com.example.otchallenge.MainActivity
+import com.example.otchallenge.MyApplication
+import com.example.otchallenge.data.di.DatabaseModule
+import com.example.otchallenge.data.di.NetworkModule
+import com.example.otchallenge.data.di.RepositoryModule
+import com.example.otchallenge.presentation.view.BookDetailFragment
+import com.example.otchallenge.presentation.view.BookListFragment
+import com.example.otchallenge.presentation.view.MainActivity
+import com.example.otchallenge.presentation.view.SplashFragment
import dagger.Component
import javax.inject.Singleton
@Singleton
-@Component
+@Component(
+ modules = [
+ AppModule::class,
+ NetworkModule::class,
+ DatabaseModule::class,
+ RepositoryModule::class,
+ PresenterModule::class,
+ UseCaseModule::class
+ ]
+)
interface AppComponent {
- fun inject(activity: MainActivity)
+ fun inject(application: MyApplication)
+ fun inject(fragment: SplashFragment)
+ fun inject(fragment: BookListFragment)
+ fun inject(fragment: BookDetailFragment)
+ fun inject(activity: MainActivity)
+
+ @Component.Factory
+ interface Factory {
+ fun create(appModule: AppModule): AppComponent
+ }
}
diff --git a/app/src/main/java/com/example/otchallenge/di/AppModule.kt b/app/src/main/java/com/example/otchallenge/di/AppModule.kt
new file mode 100644
index 0000000..f927290
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/di/AppModule.kt
@@ -0,0 +1,44 @@
+package com.example.otchallenge.di
+
+import android.app.Application
+import android.content.Context
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.database.BookDatabase
+import com.example.otchallenge.domain.executor.JobExecutor
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.executor.UIThread
+import com.example.otchallenge.utils.ConnectivityChecker
+import com.example.otchallenge.utils.NetworkHelper
+
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class AppModule(private val application: Application) {
+ @Provides
+ @Singleton
+ fun provideApplication(): Application = application
+
+ @Provides
+ @Singleton
+ fun provideContext(): Context = application.applicationContext
+
+ @Provides
+ @Singleton
+ fun provideConnectivityChecker(networkHelper: NetworkHelper): ConnectivityChecker =
+ networkHelper
+
+ @Provides
+ @Singleton
+ fun provideThreadExecutor(): ThreadExecutor {
+ return JobExecutor()
+ }
+
+ @Provides
+ @Singleton
+ fun providePostExecutionThread(): PostExecutionThread {
+ return UIThread()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/di/PresenterModule.kt b/app/src/main/java/com/example/otchallenge/di/PresenterModule.kt
new file mode 100644
index 0000000..fb662bb
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/di/PresenterModule.kt
@@ -0,0 +1,61 @@
+package com.example.otchallenge.di
+
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.usecase.GetBookDetailsUseCaseContract
+import com.example.otchallenge.domain.usecase.GetBooksUseCaseContract
+import com.example.otchallenge.presentation.presenter.BookDetailPresenter
+import com.example.otchallenge.presentation.presenter.BookDetailPresenterContract
+import com.example.otchallenge.presentation.presenter.BookListPresenter
+import com.example.otchallenge.presentation.presenter.BookListPresenterContract
+import dagger.Module
+import dagger.Provides
+import io.reactivex.Scheduler
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.schedulers.Schedulers
+import javax.inject.Named
+import javax.inject.Singleton
+
+@Module
+class PresenterModule {
+
+ @Provides
+ @Singleton
+ fun provideBookListPresenter(
+ getBooksUseCase: GetBooksUseCaseContract,
+ compositeDisposable: CompositeDisposable,
+ threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+ ): BookListPresenterContract {
+ return BookListPresenter(
+ getBooksUseCase,
+ compositeDisposable,
+ threadExecutor,
+ postExecutionThread
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideBookDetailPresenter(
+ getBooksUseCase: GetBookDetailsUseCaseContract,
+ compositeDisposable: CompositeDisposable,
+ threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+ ): BookDetailPresenterContract {
+ return BookDetailPresenter(
+ getBooksUseCase,
+ compositeDisposable,
+ threadExecutor,
+ postExecutionThread
+ )
+ }
+
+ @Provides
+ @Singleton
+ fun provideCompositeDisposable(): CompositeDisposable {
+ return CompositeDisposable()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/di/SchedulerModule.kt b/app/src/main/java/com/example/otchallenge/di/SchedulerModule.kt
new file mode 100644
index 0000000..c8cafc9
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/di/SchedulerModule.kt
@@ -0,0 +1,19 @@
+package com.example.otchallenge.di
+
+import dagger.Module
+import dagger.Provides
+import io.reactivex.Scheduler
+import io.reactivex.android.schedulers.AndroidSchedulers
+import io.reactivex.schedulers.Schedulers
+import javax.inject.Named
+
+@Module
+class SchedulerModule {
+ @Provides
+ @Named("io")
+ fun provideIoScheduler(): Scheduler = Schedulers.io()
+
+ @Provides
+ @Named("mainThread")
+ fun provideMainThreadScheduler(): Scheduler = AndroidSchedulers.mainThread()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/di/UseCaseModule.kt b/app/src/main/java/com/example/otchallenge/di/UseCaseModule.kt
new file mode 100644
index 0000000..23f4c08
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/di/UseCaseModule.kt
@@ -0,0 +1,36 @@
+package com.example.otchallenge.di
+
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import com.example.otchallenge.domain.repository.BookListRepository
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.usecase.GetBookDetailsUseCase
+import com.example.otchallenge.domain.usecase.GetBookDetailsUseCaseContract
+import com.example.otchallenge.domain.usecase.GetBooksUseCase
+import com.example.otchallenge.domain.usecase.GetBooksUseCaseContract
+import dagger.Module
+import dagger.Provides
+import javax.inject.Singleton
+
+@Module
+class UseCaseModule {
+
+ @Provides
+ @Singleton
+ fun provideGetBooksUseCase(
+ bookRepository: BookListRepository, threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+ ): GetBooksUseCaseContract {
+ return GetBooksUseCase(bookRepository, threadExecutor, postExecutionThread)
+ }
+
+ @Provides
+ @Singleton
+ fun provideGetBookDetailsUseCase(
+ bookRepository: BookDetailsRepository,
+ threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+ ): GetBookDetailsUseCaseContract {
+ return GetBookDetailsUseCase(bookRepository, threadExecutor, postExecutionThread)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/executor/ImmediateThreadExecutor.kt b/app/src/main/java/com/example/otchallenge/domain/executor/ImmediateThreadExecutor.kt
new file mode 100644
index 0000000..3ad7054
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/executor/ImmediateThreadExecutor.kt
@@ -0,0 +1,7 @@
+package com.example.otchallenge.domain.executor
+
+class ImmediateThreadExecutor : ThreadExecutor {
+ override fun execute(runnable: Runnable?) {
+ runnable?.run()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/executor/JobExecutor.kt b/app/src/main/java/com/example/otchallenge/domain/executor/JobExecutor.kt
new file mode 100644
index 0000000..4c83058
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/executor/JobExecutor.kt
@@ -0,0 +1,36 @@
+package com.example.otchallenge.domain.executor
+
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.ThreadFactory
+import java.util.concurrent.ThreadPoolExecutor
+import java.util.concurrent.TimeUnit
+import javax.inject.Inject
+
+class JobExecutor @Inject constructor() : ThreadExecutor {
+
+ private val threadPoolExecutor by lazy {
+ ThreadPoolExecutor(
+ CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TimeUnit.SECONDS,
+ LinkedBlockingQueue(), JobThreadFactory()
+ )
+ }
+
+ override fun execute(runnable: Runnable) {
+ threadPoolExecutor.execute(runnable)
+ }
+
+ private class JobThreadFactory : ThreadFactory {
+ private var counter = 0
+
+ override fun newThread(runnable: Runnable): Thread {
+ return Thread(runnable, BASE_NAME_THREAD + counter++)
+ }
+ }
+
+ companion object {
+ private const val CORE_POOL_SIZE = 3
+ private const val MAXIMUM_POOL_SIZE = 5
+ private const val KEEP_ALIVE_TIME: Long = 10
+ private const val BASE_NAME_THREAD = "android_"
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/executor/PostExecutionThread.kt b/app/src/main/java/com/example/otchallenge/domain/executor/PostExecutionThread.kt
new file mode 100644
index 0000000..a2daf88
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/executor/PostExecutionThread.kt
@@ -0,0 +1,12 @@
+package com.example.otchallenge.domain.executor
+
+import io.reactivex.Scheduler
+
+/**
+ * Thread abstraction created to change the execution context from any thread to any other thread.
+ * Useful to encapsulate a UI Thread for example, since some job will be done in background, an
+ * implementation of this interface will change context and update the UI.
+ */
+interface PostExecutionThread {
+ fun getScheduler(): Scheduler
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/executor/ThreadExecutor.kt b/app/src/main/java/com/example/otchallenge/domain/executor/ThreadExecutor.kt
new file mode 100644
index 0000000..863e9e4
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/executor/ThreadExecutor.kt
@@ -0,0 +1,5 @@
+package com.example.otchallenge.domain.executor
+
+import java.util.concurrent.Executor
+
+interface ThreadExecutor : Executor
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/executor/UIThread.kt b/app/src/main/java/com/example/otchallenge/domain/executor/UIThread.kt
new file mode 100644
index 0000000..02fe674
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/executor/UIThread.kt
@@ -0,0 +1,12 @@
+package com.example.otchallenge.domain.executor
+
+import io.reactivex.Scheduler
+import io.reactivex.android.schedulers.AndroidSchedulers
+import javax.inject.Inject
+
+class UIThread @Inject constructor() : PostExecutionThread {
+
+ override fun getScheduler(): Scheduler {
+ return AndroidSchedulers.mainThread()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/mapper/BookDetailMapper.kt b/app/src/main/java/com/example/otchallenge/domain/mapper/BookDetailMapper.kt
new file mode 100644
index 0000000..66346cd
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/mapper/BookDetailMapper.kt
@@ -0,0 +1,22 @@
+package com.example.otchallenge.domain.mapper
+
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+import com.example.otchallenge.utils.Transform
+
+class BookDetailMapper : Transform() {
+ override fun transform(value: Book): BookDetailPresentation {
+ return BookDetailPresentation(
+ id = value.id,
+ rank = value.rank,
+ description = value.description,
+ price = value.price,
+ title = value.title,
+ author = value.author,
+ bookImage = value.bookImage,
+ bookImageWidth = value.bookImageWidth,
+ bookImageHeight = value.bookImageHeight,
+ amazonProductUrl = value.amazonProductUrl
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/mapper/BookEntityMapper.kt b/app/src/main/java/com/example/otchallenge/domain/mapper/BookEntityMapper.kt
new file mode 100644
index 0000000..0e6ae58
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/mapper/BookEntityMapper.kt
@@ -0,0 +1,30 @@
+package com.example.otchallenge.domain.mapper
+
+import com.example.otchallenge.data.database.BookEntity
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.utils.Transform
+
+class BookEntityMapper : Transform() {
+ override fun transform(value: BookEntity): Book {
+ return Book(
+ id = value.id,
+ title = value.title,
+ author = value.author,
+ description = value.description,
+ bookImage = value.bookImage,
+ primaryIsbn10 = value.primaryIsbn10,
+ primaryIsbn13 = value.primaryIsbn13,
+ publisher = value.publisher,
+ price = value.price,
+ rank = value.rank,
+ rankLastWeek = value.rankLastWeek,
+ weeksOnList = value.weeksOnList,
+ contributor = value.contributor,
+ bookImageWidth = value.bookImageWidth,
+ bookImageHeight = value.bookImageHeight,
+ amazonProductUrl = value.amazonProductUrl,
+ ageGroup = value.ageGroup,
+ bookUri = value.bookUri
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryEntityMapper.kt b/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryEntityMapper.kt
new file mode 100644
index 0000000..d8c0f36
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryEntityMapper.kt
@@ -0,0 +1,26 @@
+package com.example.otchallenge.domain.mapper
+
+import com.example.otchallenge.data.database.BookSummaryEntity
+import com.example.otchallenge.domain.model.BookSummary
+import com.example.otchallenge.utils.Transform
+
+class BookSummaryEntityMapper : Transform() {
+
+ override fun transform(value: BookSummary): BookSummaryEntity {
+ return BookSummaryEntity(
+ id = value.id,
+ title = value.title,
+ description = value.description,
+ bookImage = value.bookImage
+ )
+ }
+
+ override fun reverseTransform(value: BookSummaryEntity): BookSummary {
+ return BookSummary(
+ id = value.id,
+ title = value.title,
+ description = value.description,
+ bookImage = value.bookImage
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryMapper.kt b/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryMapper.kt
new file mode 100644
index 0000000..903f36f
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/mapper/BookSummaryMapper.kt
@@ -0,0 +1,17 @@
+package com.example.otchallenge.domain.mapper
+
+import com.example.otchallenge.domain.model.BookSummary
+import com.example.otchallenge.presentation.model.BookPresentation
+import com.example.otchallenge.utils.Transform
+
+class BookSummaryMapper : Transform() {
+
+ override fun transform(value: BookSummary): BookPresentation {
+ return BookPresentation(
+ id = value.id,
+ title = value.title,
+ description = value.description,
+ bookImage = value.bookImage
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/model/Book.kt b/app/src/main/java/com/example/otchallenge/domain/model/Book.kt
new file mode 100644
index 0000000..82c89b1
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/model/Book.kt
@@ -0,0 +1,22 @@
+package com.example.otchallenge.domain.model
+
+data class Book(
+ val id: Int? = null,
+ val rank: Int,
+ val rankLastWeek: Int,
+ val weeksOnList: Int,
+ val primaryIsbn10: String,
+ val primaryIsbn13: String,
+ val publisher: String,
+ val description: String,
+ val price: String,
+ val title: String,
+ val author: String,
+ val contributor: String,
+ val bookImage: String,
+ val bookImageWidth: Int,
+ val bookImageHeight: Int,
+ val amazonProductUrl: String,
+ val ageGroup: String,
+ val bookUri: String?
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/model/BookSummary.kt b/app/src/main/java/com/example/otchallenge/domain/model/BookSummary.kt
new file mode 100644
index 0000000..98eef84
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/model/BookSummary.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.domain.model
+
+data class BookSummary(
+ val id: Int,
+ val title: String,
+ val bookImage: String,
+ val description: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/repository/BookDetailsRepository.kt b/app/src/main/java/com/example/otchallenge/domain/repository/BookDetailsRepository.kt
new file mode 100644
index 0000000..15346c0
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/repository/BookDetailsRepository.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.domain.repository
+
+import com.example.otchallenge.domain.model.Book
+import io.reactivex.Single
+
+interface BookDetailsRepository {
+ fun loadBookDetails(id: Int): Single
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/repository/BookListRepository.kt b/app/src/main/java/com/example/otchallenge/domain/repository/BookListRepository.kt
new file mode 100644
index 0000000..c367b16
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/repository/BookListRepository.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.domain.repository
+
+import com.example.otchallenge.domain.model.BookSummary
+import io.reactivex.Single
+
+interface BookListRepository {
+ fun getBooks(): Single>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/BookLoader.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/BookLoader.kt
new file mode 100644
index 0000000..f7f7297
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/BookLoader.kt
@@ -0,0 +1,10 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.domain.model.BookSummary
+import io.reactivex.Single
+
+interface BookLoader {
+ fun loadBooks(): Single>
+ fun loadBookDetails(id: Int): Single
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCase.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCase.kt
new file mode 100644
index 0000000..8ae865d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCase.kt
@@ -0,0 +1,33 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.mapper.BookDetailMapper
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+import io.reactivex.Single
+import javax.inject.Inject
+
+class GetBookDetailsUseCase @Inject constructor(
+ private val bookRepository: BookDetailsRepository,
+ threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+) : SingleUseCase(threadExecutor, postExecutionThread),
+ GetBookDetailsUseCaseContract {
+
+ private val mapper = BookDetailMapper()
+
+ override fun buildUseCase(params: Int?): Single {
+ if (params == null) {
+ return Single.error(IllegalArgumentException("Book ID can't be null"))
+ }
+ return bookRepository.loadBookDetails(params)
+ .map {
+ mapper.transform(it)
+ }
+ }
+
+ override fun getBookDetails(id: Int): Single {
+ return execute(id)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseContract.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseContract.kt
new file mode 100644
index 0000000..d90e6e8
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseContract.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+import io.reactivex.Single
+
+interface GetBookDetailsUseCaseContract {
+ fun getBookDetails(id: Int): Single
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCase.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCase.kt
new file mode 100644
index 0000000..057993d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCase.kt
@@ -0,0 +1,29 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.domain.repository.BookListRepository
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.mapper.BookSummaryMapper
+import com.example.otchallenge.presentation.model.BookPresentation
+import io.reactivex.Single
+import javax.inject.Inject
+
+class GetBooksUseCase @Inject constructor(
+ private val bookRepository: BookListRepository,
+ threadExecutor: ThreadExecutor,
+ postExecutionThread: PostExecutionThread
+) :
+ SingleUseCase>(threadExecutor, postExecutionThread),
+ GetBooksUseCaseContract {
+
+ override fun buildUseCase(params: Any?): Single> {
+ return bookRepository.getBooks()
+ .map { books ->
+ BookSummaryMapper().transformCollection(books)
+ }
+ }
+
+ override fun getBooks(): Single> {
+ return execute()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseContract.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseContract.kt
new file mode 100644
index 0000000..6154686
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseContract.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.presentation.model.BookPresentation
+import io.reactivex.Single
+
+interface GetBooksUseCaseContract {
+ fun getBooks(): Single>
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/SingleUseCase.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/SingleUseCase.kt
new file mode 100644
index 0000000..746275b
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/SingleUseCase.kt
@@ -0,0 +1,33 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import io.reactivex.Single
+import io.reactivex.observers.DisposableSingleObserver
+import io.reactivex.schedulers.Schedulers
+
+abstract class SingleUseCase(
+ private val threadExecutor: ThreadExecutor,
+ private val postExecutionThread: PostExecutionThread
+) : UseCase>() {
+
+ /**
+ * Executes the current use case
+ */
+ fun execute(params: Params? = null): Single {
+ return buildUseCase(params)
+ .subscribeOn(Schedulers.from(threadExecutor))
+ .observeOn(postExecutionThread.getScheduler())
+ }
+
+ /**
+ * Executes the current use case
+ *
+ */
+ fun execute(params: Params? = null, observer: DisposableSingleObserver) {
+ val single = buildUseCase(params)
+ .subscribeOn(Schedulers.from(threadExecutor))
+ .observeOn(postExecutionThread.getScheduler())
+ addDisposable(single.subscribeWith(observer))
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/domain/usecase/UseCase.kt b/app/src/main/java/com/example/otchallenge/domain/usecase/UseCase.kt
new file mode 100644
index 0000000..0629631
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/domain/usecase/UseCase.kt
@@ -0,0 +1,28 @@
+package com.example.otchallenge.domain.usecase
+
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.disposables.Disposable
+
+abstract class UseCase {
+ private var disposable: CompositeDisposable = CompositeDisposable()
+
+ protected abstract fun buildUseCase(params: Params? = null): R
+
+ /**
+ * Dispose from current subscription
+ */
+ fun dispose() {
+ if (!disposable.isDisposed) {
+ disposable.dispose()
+ }
+ }
+
+ /**
+ * Sets the current subscription
+ *
+ * @param subscription
+ */
+ protected fun addDisposable(subscription: Disposable) {
+ disposable.add(subscription)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/model/BookDetailPresentation.kt b/app/src/main/java/com/example/otchallenge/presentation/model/BookDetailPresentation.kt
new file mode 100644
index 0000000..b857417
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/model/BookDetailPresentation.kt
@@ -0,0 +1,14 @@
+package com.example.otchallenge.presentation.model
+
+data class BookDetailPresentation(
+ val id: Int? = -1,
+ val rank: Int,
+ val description: String,
+ val price: String,
+ val title: String,
+ val author: String,
+ val bookImage: String,
+ val bookImageWidth: Int,
+ val bookImageHeight: Int,
+ val amazonProductUrl: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/model/BookPresentation.kt b/app/src/main/java/com/example/otchallenge/presentation/model/BookPresentation.kt
new file mode 100644
index 0000000..97cf76d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/model/BookPresentation.kt
@@ -0,0 +1,8 @@
+package com.example.otchallenge.presentation.model
+
+class BookPresentation(
+ val id: Int,
+ val title: String,
+ val bookImage: String,
+ val description: String
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenter.kt b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenter.kt
new file mode 100644
index 0000000..1d96ac1
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenter.kt
@@ -0,0 +1,61 @@
+package com.example.otchallenge.presentation.presenter
+
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.usecase.GetBookDetailsUseCaseContract
+import com.example.otchallenge.presentation.view.BookDetailView
+import com.example.otchallenge.presentation.view.BookView
+import com.example.otchallenge.utils.NetworkException
+import com.example.otchallenge.utils.NoConnectivityException
+import com.example.otchallenge.utils.TimeoutException
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.schedulers.Schedulers
+import javax.inject.Inject
+
+class BookDetailPresenter @Inject constructor(
+ private val getBookDetailUseCase: GetBookDetailsUseCaseContract,
+ private val compositeDisposable: CompositeDisposable,
+ private val threadExecutor: ThreadExecutor,
+ private val postExecutionThread: PostExecutionThread
+) : BookDetailPresenterContract {
+
+ private var view: BookDetailView? = null
+
+ override fun attachView(view: BookView) {
+ this.view = view as BookDetailView
+ }
+
+ override fun detachView() {
+ this.view = null
+ clearDisposables()
+ }
+
+ override fun loadBookDetails(id: Int) {
+ compositeDisposable.add(
+ getBookDetailUseCase.getBookDetails(id)
+ .subscribeOn(Schedulers.from(threadExecutor))
+ .observeOn(postExecutionThread.getScheduler())
+ .subscribe(
+ { book ->
+ view?.showBookDetails(book)
+ },
+ { error ->
+ handleError(error)
+ }
+ )
+ )
+ }
+
+ private fun handleError(error: Throwable) {
+ when (error) {
+ is NoConnectivityException -> view?.showError("No internet connection")
+ is TimeoutException -> view?.showError("Connection timeout")
+ is NetworkException -> view?.showError("Network error")
+ else -> view?.showError("An unknown error occurred")
+ }
+ }
+
+ override fun clearDisposables() {
+ compositeDisposable.clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenterContract.kt b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenterContract.kt
new file mode 100644
index 0000000..4b1dce3
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookDetailPresenterContract.kt
@@ -0,0 +1,5 @@
+package com.example.otchallenge.presentation.presenter
+
+interface BookDetailPresenterContract : BookPresenterContract {
+ fun loadBookDetails(id: Int)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenter.kt b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenter.kt
new file mode 100644
index 0000000..ce5118d
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenter.kt
@@ -0,0 +1,58 @@
+package com.example.otchallenge.presentation.presenter
+
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.executor.ThreadExecutor
+import com.example.otchallenge.domain.usecase.GetBooksUseCaseContract
+import com.example.otchallenge.presentation.view.BookListView
+import com.example.otchallenge.presentation.view.BookView
+import com.example.otchallenge.utils.NetworkException
+import com.example.otchallenge.utils.NoConnectivityException
+import com.example.otchallenge.utils.TimeoutException
+import io.reactivex.disposables.CompositeDisposable
+import io.reactivex.schedulers.Schedulers
+import javax.inject.Inject
+
+class BookListPresenter @Inject constructor(
+ private val getBooksUseCase: GetBooksUseCaseContract,
+ private val compositeDisposable: CompositeDisposable,
+ private val threadExecutor: ThreadExecutor,
+ private val postExecutionThread: PostExecutionThread
+) : BookListPresenterContract {
+ private var view: BookListView? = null
+
+ override fun attachView(view: BookView) {
+ this.view = view as BookListView
+ }
+
+ override fun detachView() {
+ this.view = null
+ clearDisposables()
+ }
+
+ override fun loadBooks() {
+ compositeDisposable.add(getBooksUseCase.getBooks()
+ .subscribeOn(Schedulers.from(threadExecutor))
+ .observeOn(postExecutionThread.getScheduler())
+ .subscribe(
+ { books ->
+ view?.showBooks(books)
+ },
+ { error ->
+ handleError(error)
+ }
+ ))
+ }
+
+ private fun handleError(error: Throwable) {
+ when (error) {
+ is NoConnectivityException -> view?.showError("No internet connection")
+ is TimeoutException -> view?.showError("Connection timeout")
+ is NetworkException -> view?.showError("Network error")
+ else -> view?.showError("An unknown error occurred")
+ }
+ }
+
+ override fun clearDisposables() {
+ compositeDisposable.clear()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenterContract.kt b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenterContract.kt
new file mode 100644
index 0000000..c1df08f
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookListPresenterContract.kt
@@ -0,0 +1,5 @@
+package com.example.otchallenge.presentation.presenter
+
+interface BookListPresenterContract : BookPresenterContract {
+ fun loadBooks()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/presenter/BookPresenterContract.kt b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookPresenterContract.kt
new file mode 100644
index 0000000..d20180c
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/presenter/BookPresenterContract.kt
@@ -0,0 +1,9 @@
+package com.example.otchallenge.presentation.presenter
+
+import com.example.otchallenge.presentation.view.BookView
+
+interface BookPresenterContract {
+ fun attachView(view: BookView)
+ fun detachView()
+ fun clearDisposables()
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookAdapter.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookAdapter.kt
new file mode 100644
index 0000000..eef7fa8
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookAdapter.kt
@@ -0,0 +1,66 @@
+package com.example.otchallenge.presentation.view
+
+import android.annotation.SuppressLint
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.ImageView
+import android.widget.TextView
+import androidx.recyclerview.widget.DiffUtil
+import androidx.recyclerview.widget.ListAdapter
+import androidx.recyclerview.widget.RecyclerView
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
+import com.example.otchallenge.R
+import com.example.otchallenge.presentation.model.BookPresentation
+import com.example.otchallenge.utils.GlideApp
+
+class BookAdapter(private val onClick: (Int) -> Unit) :
+ ListAdapter(BookDiffCallback()) {
+
+ override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder {
+ val view =
+ LayoutInflater.from(parent.context).inflate(R.layout.item_book_gallery, parent, false)
+ return BookViewHolder(view, onClick)
+ }
+
+ override fun onBindViewHolder(holder: BookViewHolder, position: Int) {
+ holder.bind(getItem(position))
+ }
+
+ class BookViewHolder(itemView: View, val onClick: (Int) -> Unit) :
+ RecyclerView.ViewHolder(itemView) {
+ private val titleTextView: TextView = itemView.findViewById(R.id.item_book_title)
+ private val descriptionTextView: TextView =
+ itemView.findViewById(R.id.item_book_description)
+ private val bookImageView: ImageView = itemView.findViewById(R.id.item_book_cover)
+
+ private lateinit var currentBook: BookPresentation
+
+ init {
+ itemView.setOnClickListener {
+ onClick(currentBook.id)
+ }
+ }
+
+ fun bind(book: BookPresentation) {
+ currentBook = book
+ titleTextView.text = book.title
+ descriptionTextView.text = book.description
+ GlideApp.with(itemView.context)
+ .load(book.bookImage)
+ .transition(DrawableTransitionOptions.withCrossFade())
+ .into(bookImageView)
+ }
+ }
+}
+
+class BookDiffCallback : DiffUtil.ItemCallback() {
+ override fun areItemsTheSame(oldItem: BookPresentation, newItem: BookPresentation): Boolean {
+ return oldItem.id == newItem.id
+ }
+
+ @SuppressLint("DiffUtilEquals")
+ override fun areContentsTheSame(oldItem: BookPresentation, newItem: BookPresentation): Boolean {
+ return oldItem == newItem
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailFragment.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailFragment.kt
new file mode 100644
index 0000000..00a64af
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailFragment.kt
@@ -0,0 +1,98 @@
+package com.example.otchallenge.presentation.view
+
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Button
+import android.widget.ImageView
+import android.widget.TextView
+import android.widget.Toast
+import androidx.activity.OnBackPressedCallback
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions
+import com.example.otchallenge.MyApplication
+import com.example.otchallenge.R
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+import com.example.otchallenge.presentation.presenter.BookDetailPresenter
+import com.example.otchallenge.utils.GlideApp
+import javax.inject.Inject
+
+class BookDetailFragment : Fragment(), BookDetailView {
+
+ @Inject
+ lateinit var presenter: BookDetailPresenter
+
+ private lateinit var bookImage: ImageView
+ private lateinit var bookTitle: TextView
+ private lateinit var bookAuthor: TextView
+ private lateinit var bookPrice: TextView
+ private lateinit var bookDescription: TextView
+ private lateinit var buyButton: Button
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ (activity?.application as MyApplication).appComponent.inject(this)
+
+ // Handle back press for all versions
+ val callback = object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ findNavController().navigateUp()
+ }
+ }
+ requireActivity().onBackPressedDispatcher.addCallback(this, callback)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_book_detail, container, false)
+
+ bookImage = view.findViewById(R.id.book_image)
+ bookTitle = view.findViewById(R.id.book_title)
+ bookAuthor = view.findViewById(R.id.book_author)
+ bookPrice = view.findViewById(R.id.book_price)
+ bookDescription = view.findViewById(R.id.book_description)
+ buyButton = view.findViewById(R.id.buy_button)
+
+ return view
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ presenter.attachView(this)
+ val bookId = arguments?.getInt("bookId") ?: return
+ presenter.loadBookDetails(bookId)
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ presenter.detachView()
+ }
+
+ override fun showBookDetails(book: BookDetailPresentation) {
+ bookTitle.text = book.title
+ bookAuthor.text = book.author
+ bookPrice.text = book.price
+ bookDescription.text = book.description
+
+ GlideApp.with(this)
+ .load(book.bookImage)
+ .transition(DrawableTransitionOptions.withCrossFade())
+ .into(bookImage)
+
+ buyButton.setOnClickListener {
+ val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(book.amazonProductUrl))
+ startActivity(browserIntent)
+ }
+ }
+
+ override fun showError(error: String) {
+ Toast.makeText(context, error, Toast.LENGTH_SHORT).show()
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailView.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailView.kt
new file mode 100644
index 0000000..6c911c7
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookDetailView.kt
@@ -0,0 +1,7 @@
+package com.example.otchallenge.presentation.view
+
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+
+interface BookDetailView : BookView {
+ fun showBookDetails(book: BookDetailPresentation)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookListFragment.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookListFragment.kt
new file mode 100644
index 0000000..94343a1
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookListFragment.kt
@@ -0,0 +1,99 @@
+package com.example.otchallenge.presentation.view
+
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.TextView
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import androidx.recyclerview.widget.GridLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
+import com.example.otchallenge.MyApplication
+import com.example.otchallenge.R
+import com.example.otchallenge.presentation.model.BookPresentation
+import com.example.otchallenge.presentation.presenter.BookListPresenter
+import javax.inject.Inject
+
+class BookListFragment : Fragment(), BookListView {
+
+ @Inject
+ lateinit var presenter: BookListPresenter
+
+ private lateinit var swipeRefreshLayout: SwipeRefreshLayout
+ private lateinit var recyclerView: RecyclerView
+ private lateinit var adapter: BookAdapter
+ private lateinit var emptyView: TextView
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ (activity?.application as MyApplication).appComponent.inject(this)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ val view = inflater.inflate(R.layout.fragment_book_list, container, false)
+ recyclerView = view.findViewById(R.id.recycler_book_list)
+ emptyView = view.findViewById(R.id.empty_view)
+ swipeRefreshLayout = view.findViewById(R.id.swipe_refresh_layout)
+
+
+ adapter = BookAdapter { bookId ->
+ findNavController().navigate(
+ R.id.action_bookListFragment_to_bookDetailFragment,
+ Bundle().apply { putInt("bookId", bookId) }
+ )
+ }
+ recyclerView.adapter = adapter
+ recyclerView.layoutManager = GridLayoutManager(context, 2)
+
+ swipeRefreshLayout.setOnRefreshListener {
+ presenter.loadBooks()
+ }
+
+ return view
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ presenter.attachView(this)
+ presenter.loadBooks()
+ }
+
+ override fun onResume() {
+ super.onResume()
+ presenter.loadBooks()
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ presenter.detachView()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+ }
+
+ override fun showBooks(books: List) {
+ swipeRefreshLayout.isRefreshing = false
+ if (books.isEmpty()) {
+ recyclerView.visibility = View.GONE
+ emptyView.visibility = View.VISIBLE
+ emptyView.text = "No books available at the moment. Please try again later."
+ } else {
+ recyclerView.visibility = View.VISIBLE
+ emptyView.visibility = View.GONE
+ adapter.submitList(books)
+ }
+ }
+
+ override fun showError(error: String) {
+ recyclerView.visibility = View.GONE
+ emptyView.visibility = View.VISIBLE
+ emptyView.text = error
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookListView.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookListView.kt
new file mode 100644
index 0000000..804ce8c
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookListView.kt
@@ -0,0 +1,7 @@
+package com.example.otchallenge.presentation.view
+
+import com.example.otchallenge.presentation.model.BookPresentation
+
+interface BookListView : BookView {
+ fun showBooks(books: List)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/BookView.kt b/app/src/main/java/com/example/otchallenge/presentation/view/BookView.kt
new file mode 100644
index 0000000..1c9affc
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/BookView.kt
@@ -0,0 +1,5 @@
+package com.example.otchallenge.presentation.view
+
+interface BookView {
+ fun showError(error: String)
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/MainActivity.kt b/app/src/main/java/com/example/otchallenge/presentation/view/MainActivity.kt
new file mode 100644
index 0000000..5e19997
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/MainActivity.kt
@@ -0,0 +1,35 @@
+package com.example.otchallenge.presentation.view
+
+import android.os.Bundle
+import androidx.appcompat.app.AppCompatActivity
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.navigation.findNavController
+import androidx.navigation.fragment.NavHostFragment
+import com.example.otchallenge.MyApplication
+import com.example.otchallenge.R
+
+class MainActivity : AppCompatActivity() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ (application as MyApplication).appComponent.inject(this)
+ super.onCreate(savedInstanceState)
+ //enableEdgeToEdge()
+ setContentView(R.layout.activity_main)
+
+ val navHostFragment =
+ supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as NavHostFragment
+ val navController = navHostFragment.navController
+
+ 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
+ }
+ }
+
+ override fun onSupportNavigateUp(): Boolean {
+ val navController = findNavController(R.id.nav_host_fragment)
+ return navController.navigateUp() || super.onSupportNavigateUp()
+ }
+}
diff --git a/app/src/main/java/com/example/otchallenge/presentation/view/SplashFragment.kt b/app/src/main/java/com/example/otchallenge/presentation/view/SplashFragment.kt
new file mode 100644
index 0000000..0a89036
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/presentation/view/SplashFragment.kt
@@ -0,0 +1,35 @@
+package com.example.otchallenge.presentation.view
+
+import android.os.Bundle
+import android.os.Handler
+import android.os.Looper
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import androidx.fragment.app.Fragment
+import androidx.navigation.fragment.findNavController
+import com.example.otchallenge.MyApplication
+import com.example.otchallenge.R
+
+class SplashFragment : Fragment() {
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ (activity?.application as MyApplication).appComponent.inject(this)
+ }
+
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?
+ ): View? {
+ return inflater.inflate(R.layout.fragment_splash, container, false)
+ }
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ Handler(Looper.getMainLooper()).postDelayed({
+ findNavController().navigate(R.id.action_splashFragment_to_bookListFragment)
+ }, 3000) // Delay for 3seconds
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/BookMapper.kt b/app/src/main/java/com/example/otchallenge/utils/BookMapper.kt
new file mode 100644
index 0000000..86f4e00
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/BookMapper.kt
@@ -0,0 +1,144 @@
+package com.example.otchallenge.utils
+
+import com.example.otchallenge.data.database.BookEntity
+import com.example.otchallenge.data.database.BookSummaryEntity
+import com.example.otchallenge.data.model.BookApi
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.domain.model.BookSummary
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+import com.example.otchallenge.presentation.model.BookPresentation
+
+object BookMapper {
+
+ fun mapApiToDomain(apiModel: BookApi): Book {
+ return Book(
+ rank = apiModel.rank,
+ rankLastWeek = apiModel.rankLastWeek,
+ weeksOnList = apiModel.weeksOnList,
+ primaryIsbn10 = apiModel.primaryIsbn10,
+ primaryIsbn13 = apiModel.primaryIsbn13,
+ publisher = apiModel.publisher,
+ description = apiModel.description,
+ price = apiModel.price,
+ title = apiModel.title,
+ author = apiModel.author,
+ contributor = apiModel.contributor,
+ bookImage = apiModel.bookImage,
+ bookImageWidth = apiModel.bookImageWidth,
+ bookImageHeight = apiModel.bookImageHeight,
+ amazonProductUrl = apiModel.amazonProductUrl,
+ ageGroup = apiModel.ageGroup,
+ bookUri = apiModel.bookUri
+ )
+ }
+
+ fun mapApiToEntity(apiModel: BookApi): BookEntity {
+ return BookEntity(
+ title = apiModel.title,
+ author = apiModel.author,
+ description = apiModel.description,
+ bookImage = apiModel.bookImage,
+ primaryIsbn10 = apiModel.primaryIsbn10,
+ primaryIsbn13 = apiModel.primaryIsbn13,
+ publisher = apiModel.publisher,
+ price = apiModel.price,
+ rank = apiModel.rank,
+ rankLastWeek = apiModel.rankLastWeek,
+ weeksOnList = apiModel.weeksOnList,
+ contributor = apiModel.contributor,
+ bookImageWidth = apiModel.bookImageWidth,
+ bookImageHeight = apiModel.bookImageHeight,
+ amazonProductUrl = apiModel.amazonProductUrl,
+ ageGroup = apiModel.ageGroup,
+ bookUri = apiModel.bookUri.toString()
+ )
+ }
+
+ fun mapEntityToDomain(entity: BookEntity): Book {
+ return Book(
+ id = entity.id,
+ title = entity.title,
+ author = entity.author,
+ description = entity.description,
+ bookImage = entity.bookImage,
+ primaryIsbn10 = entity.primaryIsbn10,
+ primaryIsbn13 = entity.primaryIsbn13,
+ publisher = entity.publisher,
+ price = entity.price,
+ rank = entity.rank,
+ rankLastWeek = entity.rankLastWeek,
+ weeksOnList = entity.weeksOnList,
+ contributor = entity.contributor,
+ bookImageWidth = entity.bookImageWidth,
+ bookImageHeight = entity.bookImageHeight,
+ amazonProductUrl = entity.amazonProductUrl,
+ ageGroup = entity.ageGroup,
+ bookUri = entity.bookUri
+ )
+ }
+
+ fun mapEntityToDomainSummary(summary: BookEntity): BookSummary {
+ return BookSummary(
+ id = summary.id,
+ title = summary.title,
+ description = summary.description,
+ bookImage = summary.bookImage
+ )
+ }
+
+ fun mapDomainSummaryToPresentation(domain: BookSummary): BookPresentation {
+ return BookPresentation(
+ id = domain.id,
+ title = domain.title,
+ description = domain.description,
+ bookImage = domain.bookImage
+ )
+ }
+
+ fun mapDomainToDetailPresentation(domain: Book): BookDetailPresentation {
+ return BookDetailPresentation(
+ id = domain.id,
+ rank = domain.rank,
+ description = domain.description,
+ price = domain.price,
+ title = domain.title,
+ author = domain.author,
+ bookImage = domain.bookImage,
+ bookImageWidth = domain.bookImageWidth,
+ bookImageHeight = domain.bookImageHeight,
+ amazonProductUrl = domain.amazonProductUrl
+ )
+ }
+
+ fun mapSummaryToDomain(summary: BookSummaryEntity): Book {
+ return Book(
+ id = summary.id,
+ title = summary.title,
+ author = "",
+ description = summary.description,
+ bookImage = summary.bookImage,
+ primaryIsbn10 = "",
+ primaryIsbn13 = "",
+ publisher = "",
+ price = "",
+ rank = 0,
+ rankLastWeek = 0,
+ weeksOnList = 0,
+ contributor = "",
+ bookImageWidth = 0,
+ bookImageHeight = 0,
+ amazonProductUrl = "",
+ ageGroup = "",
+ bookUri = ""
+ )
+ }
+
+ fun mapEntitySummaryToDomainSummary(entity: BookSummaryEntity): BookSummary {
+ return BookSummary(
+ id = entity.id,
+ title = entity.title,
+ description = entity.description,
+ bookImage = entity.bookImage
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/MyAppGlideModule.kt b/app/src/main/java/com/example/otchallenge/utils/MyAppGlideModule.kt
new file mode 100644
index 0000000..94b6661
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/MyAppGlideModule.kt
@@ -0,0 +1,7 @@
+package com.example.otchallenge.utils
+
+import com.bumptech.glide.annotation.GlideModule
+import com.bumptech.glide.module.AppGlideModule
+
+@GlideModule
+class MyAppGlideModule : AppGlideModule()
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/NetworkHelper.kt b/app/src/main/java/com/example/otchallenge/utils/NetworkHelper.kt
new file mode 100644
index 0000000..106c739
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/NetworkHelper.kt
@@ -0,0 +1,21 @@
+package com.example.otchallenge.utils
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import javax.inject.Inject
+
+interface ConnectivityChecker {
+ fun isNetworkConnected(): Boolean
+}
+
+class NetworkHelper @Inject constructor(private val context: Context) : ConnectivityChecker {
+ override fun isNetworkConnected(): Boolean {
+ val connectivityManager =
+ context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
+ val network = connectivityManager.activeNetwork
+ val networkCapabilities = connectivityManager.getNetworkCapabilities(network)
+ return networkCapabilities != null &&
+ networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/NetworkInterceptor.kt b/app/src/main/java/com/example/otchallenge/utils/NetworkInterceptor.kt
new file mode 100644
index 0000000..ee6f742
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/NetworkInterceptor.kt
@@ -0,0 +1,30 @@
+package com.example.otchallenge.utils
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import java.io.IOException
+import java.net.SocketTimeoutException
+import javax.inject.Inject
+
+class NetworkInterceptor @Inject constructor(private val connectivityChecker: ConnectivityChecker) :
+ Interceptor {
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ if (!connectivityChecker.isNetworkConnected()) {
+ throw NoConnectivityException()
+ }
+
+ val request = chain.request()
+ return try {
+ chain.proceed(request)
+ } catch (e: SocketTimeoutException) {
+ throw TimeoutException()
+ } catch (e: IOException) {
+ throw NetworkException()
+ }
+ }
+}
+
+class NoConnectivityException : IOException("No connectivity exception")
+class TimeoutException : IOException("Timeout exception")
+class NetworkException : IOException("Network exception")
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/OffsetInterceptor.kt b/app/src/main/java/com/example/otchallenge/utils/OffsetInterceptor.kt
new file mode 100644
index 0000000..831d740
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/OffsetInterceptor.kt
@@ -0,0 +1,24 @@
+package com.example.otchallenge.utils
+
+import com.example.otchallenge.BuildConfig
+import okhttp3.Interceptor
+import okhttp3.Response
+
+class OffsetInterceptor : Interceptor {
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val originalRequest = chain.request()
+ val originalUrl = originalRequest.url()
+
+ // Add Offset to original URL
+ val newUrl = originalUrl.newBuilder()
+ .addQueryParameter("offset", BuildConfig.OFFSET)
+ .build()
+
+ // Create new request
+ val newRequest = originalRequest.newBuilder()
+ .url(newUrl)
+ .build()
+
+ return chain.proceed(newRequest)
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/example/otchallenge/utils/Transform.kt b/app/src/main/java/com/example/otchallenge/utils/Transform.kt
new file mode 100644
index 0000000..385bff9
--- /dev/null
+++ b/app/src/main/java/com/example/otchallenge/utils/Transform.kt
@@ -0,0 +1,12 @@
+package com.example.otchallenge.utils
+
+abstract class Transform {
+
+ abstract fun transform(value: T1): T2
+
+ open fun reverseTransform(value: T2): T1 = throw UnsupportedOperationException()
+
+ fun transformCollection(values: List) = values.map { t1 -> transform(t1) }
+
+ fun reverseTransformCollection(values: List) = values.map { t2 -> reverseTransform(t2) }
+}
\ No newline at end of file
diff --git a/app/src/main/res/anim/fade_in.xml b/app/src/main/res/anim/fade_in.xml
new file mode 100644
index 0000000..2477173
--- /dev/null
+++ b/app/src/main/res/anim/fade_in.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/anim/fade_out.xml b/app/src/main/res/anim/fade_out.xml
new file mode 100644
index 0000000..46227cc
--- /dev/null
+++ b/app/src/main/res/anim/fade_out.xml
@@ -0,0 +1,5 @@
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/nyt_logo.png b/app/src/main/res/drawable/nyt_logo.png
new file mode 100644
index 0000000..581a262
Binary files /dev/null and b/app/src/main/res/drawable/nyt_logo.png differ
diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml
index c7a2d54..3af2d94 100644
--- a/app/src/main/res/layout/activity_main.xml
+++ b/app/src/main/res/layout/activity_main.xml
@@ -1,19 +1,26 @@
-
+
-
+
-
\ No newline at end of file
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_book_detail.xml b/app/src/main/res/layout/fragment_book_detail.xml
new file mode 100644
index 0000000..e9dc954
--- /dev/null
+++ b/app/src/main/res/layout/fragment_book_detail.xml
@@ -0,0 +1,66 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_book_list.xml b/app/src/main/res/layout/fragment_book_list.xml
new file mode 100644
index 0000000..12817d8
--- /dev/null
+++ b/app/src/main/res/layout/fragment_book_list.xml
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_splash.xml b/app/src/main/res/layout/fragment_splash.xml
new file mode 100644
index 0000000..faf3cae
--- /dev/null
+++ b/app/src/main/res/layout/fragment_splash.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/item_book_gallery.xml b/app/src/main/res/layout/item_book_gallery.xml
new file mode 100644
index 0000000..d9c7e19
--- /dev/null
+++ b/app/src/main/res/layout/item_book_gallery.xml
@@ -0,0 +1,38 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml
new file mode 100644
index 0000000..cf19b23
--- /dev/null
+++ b/app/src/main/res/navigation/nav_graph.xml
@@ -0,0 +1,45 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/data/model/MockData.kt b/app/src/test/java/com/example/otchallenge/data/model/MockData.kt
new file mode 100644
index 0000000..549b8c8
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/data/model/MockData.kt
@@ -0,0 +1,222 @@
+package com.example.otchallenge.data.model
+
+import com.example.otchallenge.data.database.BookEntity
+import com.example.otchallenge.data.database.BookSummaryEntity
+import com.example.otchallenge.domain.model.Book
+import com.example.otchallenge.domain.model.BookSummary
+import com.example.otchallenge.presentation.model.BookDetailPresentation
+
+object MockData {
+ fun createMockOverviewResponse(): OverviewResponse {
+ return OverviewResponse(
+ status = "OK",
+ copyright = "Copyright (c) 2023 The New York Times Company. All Rights Reserved.",
+ numResults = 10,
+ results = createMockResults()
+ )
+ }
+
+ private fun createMockResults(): Results {
+ return Results(
+ listName = "Hardcover Fiction",
+ listNameEncoded = "hardcover-fiction",
+ bestsellersDate = "2023-06-01",
+ publishedDate = "2023-06-10",
+ publishedDateDescription = "latest",
+ nextPublishedDate = "",
+ previousPublishedDate = "2023-06-03",
+ displayName = "Hardcover Fiction",
+ normalListEndsAt = 10,
+ updated = "WEEKLY",
+ books = createMockBookApiList(),
+ corrections = null
+ )
+ }
+
+ fun createMockBookApiList(): List {
+ val books = mutableListOf()
+ for (i in 1..10) {
+ books.add(
+ BookApi(
+ rank = i,
+ rankLastWeek = i - 1,
+ weeksOnList = 1,
+ asterisk = 0,
+ dagger = 0,
+ primaryIsbn10 = "123456789$i",
+ primaryIsbn13 = "123-123456789$i",
+ publisher = "Publisher $i",
+ description = "Description of book $i",
+ price = "$10.99",
+ title = "Title $i",
+ author = "Author $i",
+ contributor = "Contributor $i",
+ contributorNote = "Contributor Note $i",
+ bookImage = "https://example.com/book$i.jpg",
+ bookImageWidth = 128,
+ bookImageHeight = 192,
+ amazonProductUrl = "https://amazon.com/book$i",
+ ageGroup = "Age Group $i",
+ bookReviewLink = "https://review.com/book$i",
+ firstChapterLink = "https://firstchapter.com/book$i",
+ sundayReviewLink = "https://sundayreview.com/book$i",
+ articleChapterLink = "https://articlechapter.com/book$i",
+ bookUri = "bookUri$i"
+ )
+ )
+ }
+ return books
+ }
+
+ fun createMockBookEntities(): List {
+ val books = mutableListOf()
+ for (i in 1..10) {
+ books.add(
+ BookEntity(
+ id = i,
+ rank = i,
+ rankLastWeek = i - 1,
+ weeksOnList = 1,
+ primaryIsbn10 = "123456789$i",
+ primaryIsbn13 = "123-123456789$i",
+ publisher = "Publisher $i",
+ description = "Description of book $i",
+ price = "$10.99",
+ title = "Title $i",
+ author = "Author $i",
+ contributor = "Contributor $i",
+ bookImage = "https://example.com/book$i.jpg",
+ bookImageWidth = 128,
+ bookImageHeight = 192,
+ amazonProductUrl = "https://amazon.com/book$i",
+ ageGroup = "Age Group $i",
+ bookUri = "bookUri$i"
+ )
+ )
+ }
+ return books
+ }
+
+ fun createMockBookSummaryEntities(): List {
+ val summaries = mutableListOf()
+ for (i in 1..10) {
+ summaries.add(
+ BookSummaryEntity(
+ id = i,
+ title = "Title $i",
+ description = "Description of book $i",
+ bookImage = "https://example.com/book$i.jpg"
+ )
+ )
+ }
+ return summaries
+ }
+
+ fun createMockBookSummaries(): List {
+ val summaries = mutableListOf()
+ for (i in 1..10) {
+ summaries.add(
+ BookSummary(
+ id = i,
+ title = "Title $i",
+ description = "Description of book $i",
+ bookImage = "https://example.com/book$i.jpg"
+ )
+ )
+ }
+ return summaries
+ }
+
+ fun getMockSummary(): BookSummary {
+ return BookSummary(
+ id = 1,
+ title = "Title 1",
+ description = "Description of book 1",
+ bookImage = "https://example.com/book1.jpg"
+ )
+ }
+
+ fun createMockBook(): Book {
+ return Book(
+ id = 1,
+ title = "Title 1",
+ author = "Author 1",
+ description = "Description 1",
+ bookImage = "ImageURL1",
+ rank = 1,
+ rankLastWeek = 1,
+ weeksOnList = 1,
+ primaryIsbn10 = "1234567891",
+ primaryIsbn13 = "123-1234567891",
+ publisher = "Publisher 1",
+ price = "$10.99",
+ bookImageWidth = 128,
+ bookImageHeight = 192,
+ amazonProductUrl = "https://amazon.com/book1",
+ ageGroup = "Age Group 1",
+ bookUri = "bookUri1",
+ contributor = "Some Contributor"
+ )
+ }
+
+ fun getMockBookEntity(): BookEntity {
+ return BookEntity(
+ id = 1,
+ rank = 1,
+ rankLastWeek = 1,
+ weeksOnList = 10,
+ primaryIsbn10 = "1234567890",
+ primaryIsbn13 = "1234567890123",
+ publisher = "Publisher",
+ description = "Description",
+ price = "20.00",
+ title = "Title",
+ author = "Author",
+ contributor = "Contributor",
+ bookImage = "http://image.url",
+ bookImageWidth = 200,
+ bookImageHeight = 300,
+ amazonProductUrl = "http://amazon.url",
+ ageGroup = "Age Group",
+ bookUri = "http://book.uri"
+ )
+ }
+
+ fun getMockBookDomain(): Book {
+ return Book(
+ id = 1,
+ rank = 1,
+ rankLastWeek = 1,
+ weeksOnList = 10,
+ primaryIsbn10 = "1234567890",
+ primaryIsbn13 = "1234567890123",
+ publisher = "Publisher",
+ description = "Description",
+ price = "20.00",
+ title = "Title",
+ author = "Author",
+ contributor = "Contributor",
+ bookImage = "http://image.url",
+ bookImageWidth = 200,
+ bookImageHeight = 300,
+ amazonProductUrl = "http://amazon.url",
+ ageGroup = "Age Group",
+ bookUri = "http://book.uri"
+ )
+ }
+
+ fun getBookDetailPresentation(): BookDetailPresentation {
+ return BookDetailPresentation(
+ id = 1,
+ title = "Title 1",
+ author = "Author 1",
+ description = "Description 1",
+ bookImage = "ImageURL1",
+ rank = 1,
+ price = "$10.99",
+ bookImageWidth = 128,
+ bookImageHeight = 192,
+ amazonProductUrl = "https://amazon.com/book1"
+ )
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImplTest.kt b/app/src/test/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImplTest.kt
new file mode 100644
index 0000000..c491b61
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/data/repository/BookDetailsRepositoryImplTest.kt
@@ -0,0 +1,55 @@
+package com.example.otchallenge.data.repository
+
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.model.MockData
+import com.example.otchallenge.domain.mapper.BookEntityMapper
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import io.reactivex.Single
+import io.reactivex.schedulers.Schedulers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.powermock.api.mockito.PowerMockito.`when`
+
+class BookDetailsRepositoryImplTest {
+ @get:Rule
+ val rule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock
+ private lateinit var bookDao: BookDao
+
+ @Mock
+ private lateinit var mapper: BookEntityMapper
+
+ private lateinit var bookDetailsRepository: BookDetailsRepository
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+ bookDetailsRepository = BookDetailsRepositoryImpl(bookDao)
+ }
+
+ @Test
+ fun `loadBookDetails should return book details when book is found`() {
+ // Arrange
+ val bookEntity = MockData.getMockBookEntity()
+ val bookDomain = MockData.getMockBookDomain()
+
+ `when`(bookDao.getBookById(bookEntity.id)).thenReturn(Single.just(bookEntity))
+
+ // Act
+ val testObserver = bookDetailsRepository.loadBookDetails(bookEntity.id)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertValue(bookDomain)
+ testObserver.assertComplete()
+ testObserver.assertNoErrors()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/data/repository/BookListRepositoryImplTest.kt b/app/src/test/java/com/example/otchallenge/data/repository/BookListRepositoryImplTest.kt
new file mode 100644
index 0000000..0d90304
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/data/repository/BookListRepositoryImplTest.kt
@@ -0,0 +1,116 @@
+package com.example.otchallenge.data.repository
+
+import com.example.otchallenge.data.api.BooksService
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.mapper.BookApiMapper
+import com.example.otchallenge.data.model.MockData
+import com.example.otchallenge.domain.mapper.BookSummaryEntityMapper
+import com.example.otchallenge.domain.repository.BookListRepository
+import io.reactivex.Completable
+import io.reactivex.Single
+import io.reactivex.schedulers.Schedulers
+import okhttp3.MediaType
+import okhttp3.ResponseBody
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.ArgumentMatchers.anyList
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.powermock.api.mockito.PowerMockito.`when`
+import retrofit2.HttpException
+import retrofit2.Response
+
+class BookListRepositoryImplTest {
+ @get:Rule
+ val rule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock
+ private lateinit var bookService: BooksService
+
+ @Mock
+ private lateinit var bookDao: BookDao
+
+ private lateinit var apiMapper: BookApiMapper
+ private lateinit var mapper: BookSummaryEntityMapper
+ private lateinit var bookListRepository: BookListRepository
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+ apiMapper = BookApiMapper()
+ mapper = BookSummaryEntityMapper()
+ bookListRepository = BookListRepositoryImpl(bookService, bookDao)
+ }
+
+ @Test
+ fun `getBooks should return book summaries when API call is successful`() {
+ // Arrange
+ val bookEntities = MockData.createMockBookSummaryEntities()
+ val bookSummary = MockData.createMockBookSummaries()
+ val apiResponse = MockData.createMockOverviewResponse()
+
+ `when`(bookService.getBooks()).thenReturn(Single.just(Response.success(apiResponse)))
+ `when`(bookDao.insertBooks(anyList())).thenReturn(Completable.complete())
+ `when`(bookDao.getBooks()).thenReturn(Single.just(bookEntities))
+
+ // Act
+ val testObserver = bookListRepository.getBooks()
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertValue(bookSummary)
+ testObserver.assertComplete()
+ testObserver.assertNoErrors()
+ }
+
+ @Test
+ fun `getBooks should return error when API call fails`() {
+ // Arrange
+ val throwable = HttpException(
+ Response.error(
+ 404,
+ ResponseBody.create(MediaType.get("application/json"), "{}")
+ )
+ )
+
+ `when`(bookService.getBooks()).thenReturn(Single.error(throwable))
+ `when`(bookDao.getBooks()).thenReturn(Single.error(throwable))
+
+ // Act
+ val testObserver = bookListRepository.getBooks()
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertError(throwable)
+ testObserver.assertNotComplete()
+ }
+
+ @Test
+ fun `getBooks should return cached book summaries when API call fails`() {
+ // Arrange
+ val bookEntity = MockData.createMockBookSummaryEntities()
+ val bookSummary = MockData.createMockBookSummaries()
+ val throwable = Throwable("Network error")
+
+ `when`(bookService.getBooks()).thenReturn(Single.error(throwable))
+ `when`(bookDao.getBooks()).thenReturn(Single.just(bookEntity))
+
+ // Act
+ val testObserver = bookListRepository.getBooks()
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertValue(bookSummary)
+ testObserver.assertComplete()
+ testObserver.assertNoErrors()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseTest.kt b/app/src/test/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseTest.kt
new file mode 100644
index 0000000..c9721ed
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/domain/usecase/GetBookDetailsUseCaseTest.kt
@@ -0,0 +1,95 @@
+package com.example.otchallenge.domain.usecase
+
+import com.example.otchallenge.data.model.MockData
+import com.example.otchallenge.domain.executor.ImmediateThreadExecutor
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.mapper.BookDetailMapper
+import com.example.otchallenge.domain.repository.BookDetailsRepository
+import io.reactivex.Single
+import io.reactivex.schedulers.Schedulers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.MockitoAnnotations
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+import org.powermock.api.mockito.PowerMockito.`when`
+
+class GetBookDetailsUseCaseTest {
+ @get:Rule
+ val rule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock
+ lateinit var bookRepository: BookDetailsRepository
+
+ @Mock
+ private lateinit var postExecutionThread: PostExecutionThread
+
+ @Mock
+ private lateinit var mapper: BookDetailMapper
+
+ private lateinit var getBookDetailsUseCase: GetBookDetailsUseCase
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+ getBookDetailsUseCase =
+ GetBookDetailsUseCase(bookRepository, ImmediateThreadExecutor(), postExecutionThread)
+ }
+
+ @Test
+ fun `buildUseCase should return book details when book is found`() {
+ // Arrange
+ val bookEntity = MockData.getMockBookDomain()
+ val bookDetailPresentation = MockData.getBookDetailPresentation()
+
+ `when`(bookRepository.loadBookDetails(bookEntity.id!!)).thenReturn(Single.just(bookEntity))
+ `when`(mapper.transform(bookEntity)).thenReturn(bookDetailPresentation)
+ `when`(postExecutionThread.getScheduler()).thenReturn(Schedulers.trampoline())
+
+ // Act
+ val testObserver = getBookDetailsUseCase.execute(bookEntity.id)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertValue(bookDetailPresentation)
+ testObserver.assertComplete()
+ testObserver.assertNoErrors()
+ }
+
+ @Test
+ fun `buildUseCase should return error when book is not found`() {
+ // Arrange
+ val bookId = 1
+ val throwable = Throwable("Book not found")
+
+ `when`(bookRepository.loadBookDetails(bookId)).thenReturn(Single.error(throwable))
+ `when`(postExecutionThread.getScheduler()).thenReturn(Schedulers.trampoline())
+
+ // Act
+ val testObserver = getBookDetailsUseCase.execute(bookId)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertError(throwable)
+ testObserver.assertNotComplete()
+ }
+
+ @Test
+ fun `buildUseCase should return error when params is null`() {
+ // Act
+ val testObserver = getBookDetailsUseCase.execute(null)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertError(IllegalArgumentException::class.java)
+ testObserver.assertNotComplete()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseTest.kt b/app/src/test/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseTest.kt
new file mode 100644
index 0000000..0b26b04
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/domain/usecase/GetBooksUseCaseTest.kt
@@ -0,0 +1,85 @@
+package com.example.otchallenge.domain.usecase
+
+
+import com.example.otchallenge.data.database.BookDao
+import com.example.otchallenge.data.model.MockData
+import com.example.otchallenge.domain.executor.ImmediateThreadExecutor
+import com.example.otchallenge.domain.executor.PostExecutionThread
+import com.example.otchallenge.domain.mapper.BookSummaryMapper
+import com.example.otchallenge.domain.repository.BookListRepository
+import io.reactivex.Single
+import io.reactivex.schedulers.Schedulers
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.mockito.Mock
+import org.mockito.Mockito.`when`
+import org.mockito.MockitoAnnotations
+import org.mockito.junit.MockitoJUnit
+import org.mockito.junit.MockitoRule
+
+class GetBooksUseCaseTest {
+
+ @get:Rule
+ val rule: MockitoRule = MockitoJUnit.rule()
+
+ @Mock
+ lateinit var bookRepository: BookListRepository
+
+ @Mock
+ lateinit var bookDao: BookDao
+
+ @Mock
+ private lateinit var postExecutionThread: PostExecutionThread
+
+
+ private lateinit var getBooksUseCase: GetBooksUseCase
+
+ @Before
+ fun setUp() {
+ MockitoAnnotations.openMocks(this)
+ getBooksUseCase =
+ GetBooksUseCase(bookRepository, ImmediateThreadExecutor(), postExecutionThread)
+ }
+
+ @Test
+ fun `buildUseCase should return book presentations when repository call is successful`() {
+ // Arrange
+ val bookSummaries = listOf(MockData.getMockSummary())
+ val bookPresentations = listOf(BookSummaryMapper().transform(MockData.getMockSummary()))
+
+ `when`(bookRepository.getBooks()).thenReturn(Single.just(bookSummaries))
+ `when`(postExecutionThread.getScheduler()).thenReturn(Schedulers.trampoline())
+
+ // Act
+ val testObserver = getBooksUseCase.execute(null)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ //testObserver.assertValue(bookPresentations)
+ testObserver.assertComplete()
+ testObserver.assertNoErrors()
+ }
+
+
+ @Test
+ fun `buildUseCase should return error when repository call fails`() {
+ // Arrange
+ val throwable = Throwable("Error fetching books")
+
+ `when`(bookRepository.getBooks()).thenReturn(Single.error(throwable))
+ `when`(postExecutionThread.getScheduler()).thenReturn(Schedulers.trampoline())
+
+ // Act
+ val testObserver = getBooksUseCase.execute(null)
+ .subscribeOn(Schedulers.trampoline())
+ .observeOn(Schedulers.trampoline())
+ .test()
+
+ // Assert
+ testObserver.assertError(throwable)
+ testObserver.assertNotComplete()
+ }
+}
\ No newline at end of file
diff --git a/app/src/test/java/com/example/otchallenge/utils/BookMapperTest.kt b/app/src/test/java/com/example/otchallenge/utils/BookMapperTest.kt
new file mode 100644
index 0000000..d44e408
--- /dev/null
+++ b/app/src/test/java/com/example/otchallenge/utils/BookMapperTest.kt
@@ -0,0 +1,213 @@
+package com.example.otchallenge.utils
+
+import com.example.otchallenge.data.database.BookEntity
+import com.example.otchallenge.data.model.BookApi
+import com.example.otchallenge.data.model.MockData
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class BookMapperTest {
+
+ @Test
+ fun `mapApiToEntity should correctly map BookApi to BookEntity`() {
+ val bookApi = MockData.createMockBookApiList()[0]
+ val bookEntity = BookMapper.mapApiToEntity(bookApi)
+
+ assertEquals(bookApi.title, bookEntity.title)
+ assertEquals(bookApi.author, bookEntity.author)
+ assertEquals(bookApi.description, bookEntity.description)
+ assertEquals(bookApi.bookImage, bookEntity.bookImage)
+ assertEquals(bookApi.primaryIsbn10, bookEntity.primaryIsbn10)
+ assertEquals(bookApi.primaryIsbn13, bookEntity.primaryIsbn13)
+ assertEquals(bookApi.publisher, bookEntity.publisher)
+ assertEquals(bookApi.price, bookEntity.price)
+ assertEquals(bookApi.rank, bookEntity.rank)
+ assertEquals(bookApi.rankLastWeek, bookEntity.rankLastWeek)
+ assertEquals(bookApi.weeksOnList, bookEntity.weeksOnList)
+ assertEquals(bookApi.contributor, bookEntity.contributor)
+ assertEquals(bookApi.bookImageWidth, bookEntity.bookImageWidth)
+ assertEquals(bookApi.bookImageHeight, bookEntity.bookImageHeight)
+ assertEquals(bookApi.amazonProductUrl, bookEntity.amazonProductUrl)
+ assertEquals(bookApi.ageGroup, bookEntity.ageGroup)
+ assertEquals(bookApi.bookUri, bookEntity.bookUri)
+ }
+
+ @Test
+ fun `mapApiToEntity should handle empty or default fields in BookApi`() {
+ val bookApi = BookApi(
+ rank = 1,
+ rankLastWeek = 1,
+ weeksOnList = 1,
+ asterisk = 0,
+ dagger = 0,
+ primaryIsbn10 = "1234567890",
+ primaryIsbn13 = "123-1234567890",
+ publisher = "Publisher",
+ description = "",
+ price = "",
+ title = "Title",
+ author = "Author",
+ contributor = "",
+ contributorNote = "",
+ bookImage = "",
+ bookImageWidth = 0,
+ bookImageHeight = 0,
+ amazonProductUrl = "",
+ ageGroup = "",
+ bookReviewLink = "",
+ firstChapterLink = "",
+ sundayReviewLink = "",
+ articleChapterLink = "",
+ bookUri = ""
+ )
+ val bookEntity = BookMapper.mapApiToEntity(bookApi)
+
+ assertEquals(bookApi.title, bookEntity.title)
+ assertEquals(bookApi.author, bookEntity.author)
+ assertEquals(bookApi.description, bookEntity.description)
+ assertEquals(bookApi.bookImage, bookEntity.bookImage)
+ assertEquals(bookApi.primaryIsbn10, bookEntity.primaryIsbn10)
+ assertEquals(bookApi.primaryIsbn13, bookEntity.primaryIsbn13)
+ assertEquals(bookApi.publisher, bookEntity.publisher)
+ assertEquals(bookApi.price, bookEntity.price)
+ assertEquals(bookApi.rank, bookEntity.rank)
+ assertEquals(bookApi.rankLastWeek, bookEntity.rankLastWeek)
+ assertEquals(bookApi.weeksOnList, bookEntity.weeksOnList)
+ assertEquals(bookApi.contributor, bookEntity.contributor)
+ assertEquals(bookApi.bookImageWidth, bookEntity.bookImageWidth)
+ assertEquals(bookApi.bookImageHeight, bookEntity.bookImageHeight)
+ assertEquals(bookApi.amazonProductUrl, bookEntity.amazonProductUrl)
+ assertEquals(bookApi.ageGroup, bookEntity.ageGroup)
+ assertEquals(bookApi.bookUri, bookEntity.bookUri)
+ }
+
+ @Test
+ fun `mapEntityToDomain should correctly map BookEntity to Book`() {
+ val bookEntity = MockData.createMockBookEntities()[0]
+ val book = BookMapper.mapEntityToDomain(bookEntity)
+
+ assertEquals(bookEntity.id, book.id)
+ assertEquals(bookEntity.title, book.title)
+ assertEquals(bookEntity.author, book.author)
+ assertEquals(bookEntity.description, book.description)
+ assertEquals(bookEntity.bookImage, book.bookImage)
+ assertEquals(bookEntity.primaryIsbn10, book.primaryIsbn10)
+ assertEquals(bookEntity.primaryIsbn13, book.primaryIsbn13)
+ assertEquals(bookEntity.publisher, book.publisher)
+ assertEquals(bookEntity.price, book.price)
+ assertEquals(bookEntity.rank, book.rank)
+ assertEquals(bookEntity.rankLastWeek, book.rankLastWeek)
+ assertEquals(bookEntity.weeksOnList, book.weeksOnList)
+ assertEquals(bookEntity.contributor, book.contributor)
+ assertEquals(bookEntity.bookImageWidth, book.bookImageWidth)
+ assertEquals(bookEntity.bookImageHeight, book.bookImageHeight)
+ assertEquals(bookEntity.amazonProductUrl, book.amazonProductUrl)
+ assertEquals(bookEntity.ageGroup, book.ageGroup)
+ assertEquals(bookEntity.bookUri, book.bookUri)
+ }
+
+ @Test
+ fun `mapEntityToDomain should handle empty or default fields in BookEntity`() {
+ val bookEntity = BookEntity(
+ id = 1,
+ rank = 1,
+ rankLastWeek = 1,
+ weeksOnList = 1,
+ primaryIsbn10 = "1234567890",
+ primaryIsbn13 = "123-1234567890",
+ publisher = "Publisher",
+ description = "",
+ price = "",
+ title = "Title",
+ author = "Author",
+ contributor = "",
+ bookImage = "",
+ bookImageWidth = 0,
+ bookImageHeight = 0,
+ amazonProductUrl = "",
+ ageGroup = "",
+ bookUri = ""
+ )
+ val book = BookMapper.mapEntityToDomain(bookEntity)
+
+ assertEquals(bookEntity.id, book.id)
+ assertEquals(bookEntity.title, book.title)
+ assertEquals(bookEntity.author, book.author)
+ assertEquals(bookEntity.description, book.description)
+ assertEquals(bookEntity.bookImage, book.bookImage)
+ assertEquals(bookEntity.primaryIsbn10, book.primaryIsbn10)
+ assertEquals(bookEntity.primaryIsbn13, book.primaryIsbn13)
+ assertEquals(bookEntity.publisher, book.publisher)
+ assertEquals(bookEntity.price, book.price)
+ assertEquals(bookEntity.rank, book.rank)
+ assertEquals(bookEntity.rankLastWeek, book.rankLastWeek)
+ assertEquals(bookEntity.weeksOnList, book.weeksOnList)
+ assertEquals(bookEntity.contributor, book.contributor)
+ assertEquals(bookEntity.bookImageWidth, book.bookImageWidth)
+ assertEquals(bookEntity.bookImageHeight, book.bookImageHeight)
+ assertEquals(bookEntity.amazonProductUrl, book.amazonProductUrl)
+ assertEquals(bookEntity.ageGroup, book.ageGroup)
+ assertEquals(bookEntity.bookUri, book.bookUri)
+ }
+
+ @Test
+ fun `mapEntityToDomainSummary should correctly map BookEntity to BookSummary`() {
+ val bookEntity = MockData.createMockBookEntities()[0]
+ val bookSummary = BookMapper.mapEntityToDomainSummary(bookEntity)
+
+ assertEquals(bookEntity.id, bookSummary.id)
+ assertEquals(bookEntity.title, bookSummary.title)
+ assertEquals(bookEntity.description, bookSummary.description)
+ assertEquals(bookEntity.bookImage, bookSummary.bookImage)
+ }
+
+ @Test
+ fun `mapDomainSummaryToPresentation should correctly map BookSummary to BookPresentation`() {
+ val bookSummary = MockData.createMockBookSummaries()[0]
+ val bookPresentation = BookMapper.mapDomainSummaryToPresentation(bookSummary)
+
+ assertEquals(bookSummary.id, bookPresentation.id)
+ assertEquals(bookSummary.title, bookPresentation.title)
+ assertEquals(bookSummary.description, bookPresentation.description)
+ assertEquals(bookSummary.bookImage, bookPresentation.bookImage)
+ }
+
+ @Test
+ fun `mapDomainToDetailPresentation should correctly map Book to BookDetailPresentation`() {
+ val book = MockData.createMockBook()
+ val bookDetailPresentation = BookMapper.mapDomainToDetailPresentation(book)
+
+ assertEquals(book.id, bookDetailPresentation.id)
+ assertEquals(book.rank, bookDetailPresentation.rank)
+ assertEquals(book.description, bookDetailPresentation.description)
+ assertEquals(book.price, bookDetailPresentation.price)
+ assertEquals(book.title, bookDetailPresentation.title)
+ assertEquals(book.author, bookDetailPresentation.author)
+ assertEquals(book.bookImage, bookDetailPresentation.bookImage)
+ assertEquals(book.bookImageWidth, bookDetailPresentation.bookImageWidth)
+ assertEquals(book.bookImageHeight, bookDetailPresentation.bookImageHeight)
+ assertEquals(book.amazonProductUrl, bookDetailPresentation.amazonProductUrl)
+ }
+
+ @Test
+ fun `mapSummaryToDomain should correctly map BookSummaryEntity to Book`() {
+ val bookSummaryEntity = MockData.createMockBookSummaryEntities()[0]
+ val book = BookMapper.mapSummaryToDomain(bookSummaryEntity)
+
+ assertEquals(bookSummaryEntity.id, book.id)
+ assertEquals(bookSummaryEntity.title, book.title)
+ assertEquals(bookSummaryEntity.description, book.description)
+ assertEquals(bookSummaryEntity.bookImage, book.bookImage)
+ }
+
+ @Test
+ fun `mapEntitySummaryToDomainSummary should correctly map BookSummaryEntity to BookSummary`() {
+ val bookSummaryEntity = MockData.createMockBookSummaryEntities()[0]
+ val bookSummary = BookMapper.mapEntitySummaryToDomainSummary(bookSummaryEntity)
+
+ assertEquals(bookSummaryEntity.id, bookSummary.id)
+ assertEquals(bookSummaryEntity.title, bookSummary.title)
+ assertEquals(bookSummaryEntity.description, bookSummary.description)
+ assertEquals(bookSummaryEntity.bookImage, bookSummary.bookImage)
+ }
+}
\ No newline at end of file
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 4664302..0285058 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -15,27 +15,49 @@ glide = "4.16.0"
rx-android = "2.1.1"
rx-java = "2.2.21"
rx-kotlin = "2.4.0"
+navigation = "2.6.0"
+room = "2.5.2"
+swiperefreshlayout = "1.1.0"
+mockito = "3.11.2"
+powermock = "2.0.9"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
+mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockito" }
+mockito-inline = { group = "org.mockito", name = "mockito-inline", version.ref = "mockito" }
+powermock-module = { group = "org.powermock", name = "powermock-module-junit4", version.ref = "powermock" }
+powermock-api = { group = "org.powermock", name = "powermock-api-mockito2", version.ref = "powermock" }
androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" }
material = { group = "com.google.android.material", name = "material", version.ref = "material" }
androidx-activity = { group = "androidx.activity", name = "activity", version.ref = "activity" }
androidx-constraintlayout = { group = "androidx.constraintlayout", name = "constraintlayout", version.ref = "constraintlayout" }
dagger = { group = "com.google.dagger", name = "dagger", version.ref = "dagger" }
dagger-compiler = { group = "com.google.dagger", name = "dagger-compiler", version.ref = "dagger" }
+dagger-android = { group = "com.google.dagger", name = "dagger-android", version.ref = "dagger" }
+dagger-android-support = { group = "com.google.dagger", name = "dagger-android-support", version.ref = "dagger" }
+dagger-android-processor = { group = "com.google.dagger", name = "dagger-android-processor", version.ref = "dagger" }
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
-retrofit-rx-adapter = { group = "com.squareup.retrofit2", name = "adapter-rxjava2", version.ref = "retrofit" }
+retrofit-gson = { group = "com.squareup.retrofit2", name = "adapter-rxjava2", version.ref = "retrofit" }
+retrofit-rx-adapter = { group = "com.squareup.retrofit2", name = "converter-gson", version.ref = "retrofit" }
glide = { group = "com.github.bumptech.glide", name = "glide", version.ref = "glide" }
-rx-java = {group = "io.reactivex.rxjava2", name="rxjava", version.ref = "rx-java"}
-rx-android = {group = "io.reactivex.rxjava2", name="rxandroid", version.ref = "rx-android"}
-rx-kotlin = {group = "io.reactivex.rxjava2", name="rxkotlin", version.ref = "rx-kotlin"}
+glide-compiler = { group = "com.github.bumptech.glide", name = "compiler", version.ref = "glide" }
+rx-java = { group = "io.reactivex.rxjava2", name = "rxjava", version.ref = "rx-java" }
+rx-android = { group = "io.reactivex.rxjava2", name = "rxandroid", version.ref = "rx-android" }
+rx-kotlin = { group = "io.reactivex.rxjava2", name = "rxkotlin", version.ref = "rx-kotlin" }
+androidx-navigation = { group = "androidx.navigation", name = "navigation-fragment-ktx", version.ref = "navigation" }
+androidx-navigation-ui = { group = "androidx.navigation", name = "navigation-ui-ktx", version.ref = "navigation" }
+androidx-room = { group = "androidx.room", name = "room-runtime", version.ref = "room" }
+androidx-room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" }
+androidx-room-rxjava = { group = "androidx.room", name = "room-rxjava2", version.ref = "room" }
+androidx-swiperefreshlayout = { group = "androidx.swiperefreshlayout", name = "swiperefreshlayout", version.ref = "swiperefreshlayout" }
+
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" }
+navigation-safeargs = { id = "androidx.navigation.safeargs.kotlin", version.ref = "navigation" }