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 @@ + + + + + + + + + + + + + + + +