diff --git a/app/build.gradle b/app/build.gradle index 0d0fa1b..61b1353 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -31,6 +31,11 @@ android { kotlinOptions { jvmTarget = '1.8' } + buildToolsVersion '34.0.0' + + viewBinding { + enabled = true + } } dependencies { @@ -42,20 +47,53 @@ dependencies { implementation libs.androidx.constraintlayout // dagger implementation libs.dagger + implementation libs.core.ktx kapt libs.dagger.compiler //retrofit implementation libs.retrofit + implementation libs.converter.gson implementation libs.retrofit.rx.adapter //glide implementation libs.glide + kapt libs.compiler //reactive x implementation libs.rx.android implementation libs.rx.java implementation libs.rx.kotlin + //Room database + implementation libs.androidx.room.runtime.android + kapt libs.room.compiler + implementation libs.androidx.room.ktx + implementation libs.androidx.room.rxjava2 + + //RecyclerView + implementation libs.androidx.recyclerview + + //CardView + implementation libs.androidx.cardview + + // Mockito for mocking dependencies + testImplementation libs.mockito.core + testImplementation libs.mockito.inline + + // Mockito for Android instrumented tests + androidTestImplementation libs.mockito.android + + // MockWebServer + testImplementation libs.mockwebserver + + // For room DB test + testImplementation libs.androidx.core.testing // For LiveData and Room testing + testImplementation libs.androidx.room.testing // For Room in-memory database testing + + // Material Design + implementation libs.material + + androidTestImplementation libs.androidx.runner testImplementation libs.junit androidTestImplementation libs.androidx.junit androidTestImplementation libs.androidx.espresso.core diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a18c7a..a65ab29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,7 +2,10 @@ + + - val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) - v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom) - insets + + mainBinding = ActivityMainBinding.inflate(layoutInflater) + setContentView(mainBinding.root) + + presenter = BooksPresenter(repository) + presenter.attach(this) + + setupRecyclerView() + + // Fetch the books when the activity is created + presenter.fetchBooks() + } + + // RecyclerView, Adapter setting + private fun setupRecyclerView(){ + adapter = BooksAdapter(emptyList()) + mainBinding.recyclerView.apply { + layoutManager = LinearLayoutManager(this@MainActivity) + adapter = this@MainActivity.adapter } } + + override fun showProgress() { + mainBinding.progressBar.visibility = View.VISIBLE + } + + override fun hideProgress() { + println("hideProgress()") + mainBinding.progressBar.visibility = View.GONE + } + + override fun displayBooks(books: List) { + adapter.updateBooks(books) + mainBinding.recyclerView.visibility = View.VISIBLE + } + + override fun showError(message: String) { + Toast.makeText(this, message, Toast.LENGTH_LONG).show() + } + + override fun onDestroy() { + presenter.detach() + super.onDestroy() + } } diff --git a/app/src/main/java/com/example/otchallenge/MyApplication.kt b/app/src/main/java/com/example/otchallenge/MyApplication.kt index ba66e70..91c5721 100644 --- a/app/src/main/java/com/example/otchallenge/MyApplication.kt +++ b/app/src/main/java/com/example/otchallenge/MyApplication.kt @@ -1,8 +1,12 @@ package com.example.otchallenge import android.app.Application +import android.net.Network import com.example.otchallenge.di.AppComponent import com.example.otchallenge.di.DaggerAppComponent +import com.example.otchallenge.di.DatabaseModule +import com.example.otchallenge.di.NetworkModule +import com.example.otchallenge.di.RepositoryModule class MyApplication : Application() { @@ -10,6 +14,10 @@ class MyApplication : Application() { override fun onCreate() { super.onCreate() - appComponent = DaggerAppComponent.builder().build() + appComponent = DaggerAppComponent.builder() + .networkModule(NetworkModule()) + .databaseModule(DatabaseModule(this)) + .repositoryModule(RepositoryModule()) + .build() } } diff --git a/app/src/main/java/com/example/otchallenge/dao/BookDao.kt b/app/src/main/java/com/example/otchallenge/dao/BookDao.kt new file mode 100644 index 0000000..b15d243 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/dao/BookDao.kt @@ -0,0 +1,22 @@ +package com.example.otchallenge.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.example.otchallenge.model.Book +import io.reactivex.Single +import io.reactivex.Completable + +@Dao +interface BookDao { + @Query("SELECT * FROM books ORDER BY rank ASC") + fun getAllBooks(): Single> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insertBooks(books: List): Completable + + @Query("DELETE FROM books") + fun deleteAllBooks(): Completable +} 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..fe6a4f1 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,13 @@ package com.example.otchallenge.di +import android.content.Context import com.example.otchallenge.MainActivity +import dagger.BindsInstance import dagger.Component import javax.inject.Singleton @Singleton -@Component +@Component(modules = [NetworkModule::class, DatabaseModule::class, RepositoryModule::class]) interface AppComponent { fun inject(activity: MainActivity) } diff --git a/app/src/main/java/com/example/otchallenge/di/DatabaseModule.kt b/app/src/main/java/com/example/otchallenge/di/DatabaseModule.kt new file mode 100644 index 0000000..c671238 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/DatabaseModule.kt @@ -0,0 +1,25 @@ +package com.example.otchallenge.di + +import android.content.Context +import androidx.room.Room +import com.example.otchallenge.dao.BookDao +import com.example.otchallenge.repository.BookDatabase +import dagger.Module +import dagger.Provides +import javax.inject.Singleton + +@Module +class DatabaseModule(private val context: Context) { + + @Provides + @Singleton + fun provideDatabase(): BookDatabase { + return Room.databaseBuilder(context, BookDatabase::class.java, "books_db") + .build() + } + + @Provides + fun provideBookDao(bookDatabase: BookDatabase): BookDao{ + return bookDatabase.bookDao() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/di/NetworkModule.kt b/app/src/main/java/com/example/otchallenge/di/NetworkModule.kt new file mode 100644 index 0000000..23f4e0f --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/NetworkModule.kt @@ -0,0 +1,28 @@ +package com.example.otchallenge.di + +import com.example.otchallenge.libs.ApiService +import dagger.Module +import dagger.Provides +import retrofit2.Retrofit +import retrofit2.converter.gson.GsonConverterFactory +import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory +import javax.inject.Singleton + +@Module +class NetworkModule { + @Provides + @Singleton + fun provideRetrofit(): Retrofit { + return Retrofit.Builder() + .baseUrl("https://api.nytimes.com/") + .addConverterFactory(GsonConverterFactory.create()) + .addCallAdapterFactory(RxJava2CallAdapterFactory.create()) + .build() + } + + @Provides + @Singleton + fun provideApiService(retrofit: Retrofit): ApiService { + return retrofit.create(ApiService::class.java) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/di/RepositoryModule.kt b/app/src/main/java/com/example/otchallenge/di/RepositoryModule.kt new file mode 100644 index 0000000..4ca759e --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/di/RepositoryModule.kt @@ -0,0 +1,17 @@ +package com.example.otchallenge.di + +import android.content.Context +import com.example.otchallenge.dao.BookDao +import com.example.otchallenge.libs.ApiService +import com.example.otchallenge.repository.BookRepository +import dagger.Module +import dagger.Provides + +@Module +class RepositoryModule { + + @Provides + fun provideBookRepository(apiService: ApiService, bookDao: BookDao): BookRepository{ + return BookRepository(apiService, bookDao) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/libs/ApiService.kt b/app/src/main/java/com/example/otchallenge/libs/ApiService.kt new file mode 100644 index 0000000..5f1520a --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/libs/ApiService.kt @@ -0,0 +1,14 @@ +package com.example.otchallenge.libs + +import com.example.otchallenge.model.ApiResponse +import io.reactivex.Single +import retrofit2.http.GET +import retrofit2.http.Query + +interface ApiService { + @GET("svc/books/v3/lists/current/hardcover-fiction.json") + fun getBooks( + @Query("api-key") apiKey: String, + @Query("offset") offset: Int=0 + ): Single +} diff --git a/app/src/main/java/com/example/otchallenge/model/ApiResponse.kt b/app/src/main/java/com/example/otchallenge/model/ApiResponse.kt new file mode 100644 index 0000000..cd2742d --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/ApiResponse.kt @@ -0,0 +1,5 @@ +package com.example.otchallenge.model + +data class ApiResponse( + val results: ApiResults +) diff --git a/app/src/main/java/com/example/otchallenge/model/ApiResults.kt b/app/src/main/java/com/example/otchallenge/model/ApiResults.kt new file mode 100644 index 0000000..3c70107 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/ApiResults.kt @@ -0,0 +1,5 @@ +package com.example.otchallenge.model + +data class ApiResults( + val books: List +) diff --git a/app/src/main/java/com/example/otchallenge/model/Book.kt b/app/src/main/java/com/example/otchallenge/model/Book.kt new file mode 100644 index 0000000..0a445fe --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/model/Book.kt @@ -0,0 +1,16 @@ +package com.example.otchallenge.model + +import androidx.room.Entity +import androidx.room.PrimaryKey +//import androidx.room.ColumnInfo + +@Entity(tableName = "books") +data class Book( + @PrimaryKey + val primary_isbn13: String, + val rank: Int, + val title: String, + val author: String, + val description: String, + val book_image: String +) diff --git a/app/src/main/java/com/example/otchallenge/presenter/BooksContract.kt b/app/src/main/java/com/example/otchallenge/presenter/BooksContract.kt new file mode 100644 index 0000000..e670d9f --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/presenter/BooksContract.kt @@ -0,0 +1,18 @@ +package com.example.otchallenge.presenter + +import com.example.otchallenge.model.Book + +interface BooksContract { + interface View { + fun showProgress() + fun hideProgress() + fun displayBooks(books: List) + fun showError(message: String) + } + + interface Presenter { + fun attach(view: View) + fun detach() + fun fetchBooks() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt new file mode 100644 index 0000000..5b8e170 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/presenter/BooksPresenter.kt @@ -0,0 +1,46 @@ +package com.example.otchallenge.presenter + +import com.example.otchallenge.libs.ApiService +import com.example.otchallenge.model.ApiResponse +import com.example.otchallenge.repository.BookRepository +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.disposables.CompositeDisposable +import io.reactivex.exceptions.CompositeException + +class BooksPresenter(private val repository: BookRepository) : BooksContract.Presenter{ + private var view: BooksContract.View? = null + private val disposable = CompositeDisposable() + + override fun attach(view: BooksContract.View) { + this.view = view + } + + override fun detach() { + view = null + disposable.clear() + } + + override fun fetchBooks() { + view?.showProgress() + + val disposableTask = repository.fetchBooks() + .observeOn(AndroidSchedulers.mainThread()) // Use MainThread for Book list updates + .subscribe({books -> + view?.hideProgress() + view?.displayBooks(books) + }, {error -> + view?.hideProgress() + if (error is CompositeException) { + // Log each exception separately + error.exceptions.forEach { + println("Exception occurred: ${it.message}") + } + } else { + println("Single exception: ${error.message}") + } + view?.showError(error.message ?: "An error occurred") + }) + + disposable.add(disposableTask) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/repository/BookDatabase.kt b/app/src/main/java/com/example/otchallenge/repository/BookDatabase.kt new file mode 100644 index 0000000..6efa71c --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/repository/BookDatabase.kt @@ -0,0 +1,11 @@ +package com.example.otchallenge.repository + +import androidx.room.Database +import androidx.room.RoomDatabase +import com.example.otchallenge.dao.BookDao +import com.example.otchallenge.model.Book + +@Database(entities = [Book::class], version = 1, exportSchema = false) +abstract class BookDatabase : RoomDatabase() { + abstract fun bookDao() : BookDao +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/repository/BookRepository.kt b/app/src/main/java/com/example/otchallenge/repository/BookRepository.kt new file mode 100644 index 0000000..8fd94cf --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/repository/BookRepository.kt @@ -0,0 +1,64 @@ +package com.example.otchallenge.repository + +import android.content.Context +import android.util.Log +import android.widget.Toast +import com.example.otchallenge.dao.BookDao +import com.example.otchallenge.libs.ApiService +import com.example.otchallenge.model.Book +import com.google.gson.Gson +import io.reactivex.Single +import io.reactivex.android.schedulers.AndroidSchedulers +import io.reactivex.schedulers.Schedulers + +class BookRepository (private val apiService: ApiService, private val bookDao: BookDao){ + + private val apiKey = "KoRB4K5LRHygfjCL2AH6iQ7NeUqDAGAB" + private val gson = Gson() // Gson instance for conversion + + fun fetchBooks(): Single> { + return apiService.getBooks(apiKey) + .subscribeOn(Schedulers.io()) // Running network task on io scheduler + .map{ // map Apiresponse to a book classes + response -> + + // Convert response to plain text (JSON) and log it + val plainTextResponse = gson.toJson(response) + Log.d("API_RESPONSE", plainTextResponse) // This will output the entire response as a plain text JSON string + + response.results.books.map { + println(it) + Book( + primary_isbn13 = it.primary_isbn13, + rank = it.rank, + title = it.title, + author = it.author, + description = it.description, + book_image = it.book_image + ) + } + } + .flatMap{ // Cache the book list in Room DB + books->bookDao.deleteAllBooks() + .andThen(bookDao.insertBooks(books)) // insert books list + .doOnComplete { + Log.d("DB_INSERT", "Books successfully inserted into the database") + } + .andThen(Single.just(books)) // emit the books list + } + .onErrorResumeNext { + // If network fails, fetch from the DB + bookDao.getAllBooks() + .subscribeOn(Schedulers.io()) // Use io scheduler for DB operation + .flatMap { cachedBooks -> + if (cachedBooks.isNotEmpty()) { + Single.just(cachedBooks) + }else{ + //Single.error(it) + Single.just(emptyList()) + } + } + } + .observeOn(AndroidSchedulers.mainThread()) // Process UI task on mainThread + } +} \ No newline at end of file diff --git a/app/src/main/java/com/example/otchallenge/view/BooksAdapter.kt b/app/src/main/java/com/example/otchallenge/view/BooksAdapter.kt new file mode 100644 index 0000000..07bc157 --- /dev/null +++ b/app/src/main/java/com/example/otchallenge/view/BooksAdapter.kt @@ -0,0 +1,50 @@ +package com.example.otchallenge.view + +import android.text.Layout +import android.util.Log +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import com.bumptech.glide.Glide +import com.example.otchallenge.R +import com.example.otchallenge.databinding.ItemBookBinding +import com.example.otchallenge.model.Book + +class BooksAdapter(private var books: List) : RecyclerView.Adapter() { + + fun updateBooks(newBooks: List){ + books = newBooks + notifyDataSetChanged() + } + class BookViewHolder(private val binding: ItemBookBinding) : RecyclerView.ViewHolder(binding.root){ + fun bind(book: Book){ + binding.titleTextView.text = book.title + binding.authorTextView.text = book.author + binding.descriptionTextView.text = book.description + + //Use Glide to load the book image + Glide.with(itemView.context) + .load(book.book_image) + .placeholder(R.drawable.placeholder) + .into(binding.bookImageView) + } + } + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BookViewHolder { + val layoutInflater = LayoutInflater.from(parent.context) + val binding = ItemBookBinding.inflate(layoutInflater, parent, false) + return BookViewHolder(binding) + } + + override fun getItemCount(): Int { + return books.size + } + + override fun onBindViewHolder(holder: BookViewHolder, position: Int) { + val book = books[position] + holder.bind(book) + } + +} \ No newline at end of file diff --git a/app/src/main/res/drawable/placeholder.xml b/app/src/main/res/drawable/placeholder.xml new file mode 100644 index 0000000..fd890ce --- /dev/null +++ b/app/src/main/res/drawable/placeholder.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index c7a2d54..4ab4110 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -1,19 +1,39 @@ - + android:orientation="vertical" + tools:context=".MainActivity"> + + + - + android:layout_marginTop="4dp" + android:visibility="gone" /> - \ No newline at end of file + \ No newline at end of file diff --git a/app/src/main/res/layout/item_book.xml b/app/src/main/res/layout/item_book.xml new file mode 100644 index 0000000..6672f8c --- /dev/null +++ b/app/src/main/res/layout/item_book.xml @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index fa5fc6f..2582baa 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,5 @@ #FF000000 #FFFFFFFF + #A9A9A9 \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a2875b2..c0ee257 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,3 +1,4 @@ - AndroidOTChallenge + NY Times Book List + Book image \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index c9c411a..b0a363c 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -5,5 +5,10 @@ + +