├── .idea ├── .name ├── emacs.xml ├── vcs.xml ├── codeStyles │ ├── codeStyleConfig.xml │ └── Project.xml ├── misc.xml ├── runConfigurations.xml ├── gradle.xml └── jarRepositories.xml ├── app ├── .gitignore ├── src │ ├── main │ │ ├── res │ │ │ ├── mipmap-hdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-mdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── mipmap-xxxhdpi │ │ │ │ ├── ic_launcher.png │ │ │ │ └── ic_launcher_round.png │ │ │ ├── values │ │ │ │ ├── strings.xml │ │ │ │ ├── dimens.xml │ │ │ │ ├── colors.xml │ │ │ │ └── styles.xml │ │ │ ├── mipmap-anydpi-v26 │ │ │ │ ├── ic_launcher.xml │ │ │ │ └── ic_launcher_round.xml │ │ │ ├── menu │ │ │ │ └── menu_product_list.xml │ │ │ ├── drawable │ │ │ │ ├── ic_filter_24dp.xml │ │ │ │ ├── ic_cart_24.xml │ │ │ │ ├── ic_remove_cart_24.xml │ │ │ │ └── ic_launcher_background.xml │ │ │ ├── navigation │ │ │ │ └── nav_main.xml │ │ │ ├── layout │ │ │ │ ├── activity_main.xml │ │ │ │ ├── fragment_product_list.xml │ │ │ │ ├── item_basic_product.xml │ │ │ │ └── item_tiled_product.xml │ │ │ └── drawable-v24 │ │ │ │ └── ic_launcher_foreground.xml │ │ ├── java │ │ │ └── com │ │ │ │ └── lucasmontano │ │ │ │ └── shopping │ │ │ │ ├── ui │ │ │ │ ├── models │ │ │ │ │ ├── CartUiModel.kt │ │ │ │ │ └── ProductUiModel.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── adapters │ │ │ │ │ ├── AdapterDelegate.kt │ │ │ │ │ ├── AdapterDelegatesManager.kt │ │ │ │ │ ├── DelegationAdapter.kt │ │ │ │ │ ├── ProductAdapterDelegate.kt │ │ │ │ │ └── TileProductAdapterDelegate.kt │ │ │ │ └── ProductListFragment.kt │ │ │ │ ├── ShoppingApplication.kt │ │ │ │ ├── utilities │ │ │ │ └── Constants.kt │ │ │ │ ├── data │ │ │ │ ├── entities │ │ │ │ │ ├── ProductCartRelation.kt │ │ │ │ │ ├── ProductEntity.kt │ │ │ │ │ └── ProductShoppingEntity.kt │ │ │ │ ├── domain │ │ │ │ │ ├── ProductDomainModel.kt │ │ │ │ │ ├── CouchDomainModel.kt │ │ │ │ │ └── ChairDomainModel.kt │ │ │ │ ├── mappers │ │ │ │ │ └── ModelMappers.kt │ │ │ │ ├── dao │ │ │ │ │ ├── ProductDao.kt │ │ │ │ │ └── CartDao.kt │ │ │ │ ├── Converters.kt │ │ │ │ ├── repositories │ │ │ │ │ ├── ProductRepository.kt │ │ │ │ │ └── CartRepository.kt │ │ │ │ └── AppDatabase.kt │ │ │ │ ├── binding │ │ │ │ └── GlideAdapter.kt │ │ │ │ ├── viewmodels │ │ │ │ ├── CartViewModel.kt │ │ │ │ └── ProductListViewModel.kt │ │ │ │ ├── di │ │ │ │ └── DatabaseModule.kt │ │ │ │ └── workers │ │ │ │ └── InitDatabaseWorker.kt │ │ └── AndroidManifest.xml │ ├── androidTest │ │ └── java │ │ │ └── com │ │ │ └── lucasmontano │ │ │ └── shopping │ │ │ ├── data │ │ │ ├── InitDatabaseWorkerTest.kt │ │ │ └── ProductDaoTest.kt │ │ │ ├── MainTestRunner.kt │ │ │ ├── utilities │ │ │ └── LiveDataTestUtil.kt │ │ │ └── repositories │ │ │ └── CartRepositoriesTest.kt │ └── test │ │ └── java │ │ └── com │ │ └── lucasmontano │ │ └── shopping │ │ ├── TestUtilities.kt │ │ └── viewmodels │ │ └── ProductListViewModelTest.kt ├── proguard-rules.pro └── build.gradle ├── settings.gradle ├── gradle └── wrapper │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── README.md ├── .gitignore ├── gradle.properties ├── gradlew.bat └── gradlew /.idea/.name: -------------------------------------------------------------------------------- 1 | Shopping -------------------------------------------------------------------------------- /app/.gitignore: -------------------------------------------------------------------------------- 1 | /build -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | include ':app' 2 | rootProject.name = "Shopping" -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.jar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/gradle/wrapper/gradle-wrapper.jar -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-hdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-mdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucasmontano/shopping/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/models/CartUiModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.models 2 | 3 | data class CartUiModel(val total: String) 4 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Intro 2 | A shopping app inspired by [Sunflower](https://github.com/android/sunflower) to illustrate Android development best practices with Android Jetpack. 3 | -------------------------------------------------------------------------------- /app/src/main/res/values/strings.xml: -------------------------------------------------------------------------------- 1 | 2 | Shopping 3 | Filter by type 4 | -------------------------------------------------------------------------------- /.idea/emacs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /app/src/main/res/values/dimens.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 200dp 4 | 16dp 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ShoppingApplication.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping 2 | 3 | import android.app.Application 4 | import dagger.hilt.android.HiltAndroidApp 5 | 6 | @HiltAndroidApp 7 | class ShoppingApplication : Application() 8 | -------------------------------------------------------------------------------- /app/src/main/res/values/colors.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | #6200EE 4 | #3700B3 5 | #03DAC5 6 | -------------------------------------------------------------------------------- /.idea/codeStyles/codeStyleConfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.iml 2 | .gradle 3 | /local.properties 4 | /.idea/caches 5 | /.idea/libraries 6 | /.idea/modules.xml 7 | /.idea/workspace.xml 8 | /.idea/navEditor.xml 9 | /.idea/assetWizardSettings.xml 10 | .DS_Store 11 | /build 12 | /captures 13 | .externalNativeBuild 14 | .cxx 15 | -------------------------------------------------------------------------------- /gradle/wrapper/gradle-wrapper.properties: -------------------------------------------------------------------------------- 1 | #Sat Oct 10 10:47:42 CEST 2020 2 | distributionBase=GRADLE_USER_HOME 3 | distributionPath=wrapper/dists 4 | zipStoreBase=GRADLE_USER_HOME 5 | zipStorePath=wrapper/dists 6 | distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip 7 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/utilities/Constants.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.utilities 2 | 3 | // Data 4 | const val DATABASE_NAME = "shopping-db" 5 | const val DATA_FILENAME = "products.json" 6 | const val DATABASE_TABLE_PRODUCTS = "products" 7 | const val DATABASE_TABLE_CART = "cart" -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/models/ProductUiModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.models 2 | 3 | interface ProductUiModel { 4 | val id: String 5 | } 6 | 7 | data class TiledUiModelProduct( 8 | override val id: String, 9 | val name: String, 10 | val imageUrl: String 11 | ) : ProductUiModel 12 | 13 | data class BasicProductUiModel(override val id: String, val name: String) : ProductUiModel -------------------------------------------------------------------------------- /app/src/main/res/menu/menu_product_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_filter_24dp.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/values/styles.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 9 | 10 | -------------------------------------------------------------------------------- /app/src/main/res/navigation/nav_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/entities/ProductCartRelation.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.entities 2 | 3 | import androidx.room.Embedded 4 | import androidx.room.Relation 5 | 6 | // TODO check if we need to have redudance of products in the cart 7 | data class ProductCartRelation( 8 | @Embedded 9 | val product: ProductEntity, 10 | 11 | @Relation(parentColumn = "id", entityColumn = "product_id") 12 | val productShoppingEntities: List = emptyList() 13 | ) 14 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/MainActivity.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui 2 | 3 | import android.os.Bundle 4 | import androidx.appcompat.app.AppCompatActivity 5 | import com.lucasmontano.shopping.R 6 | import dagger.hilt.android.AndroidEntryPoint 7 | 8 | @AndroidEntryPoint 9 | class MainActivity : AppCompatActivity() { 10 | 11 | override fun onCreate(savedInstanceState: Bundle?) { 12 | super.onCreate(savedInstanceState) 13 | setContentView(R.layout.activity_main) 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | -------------------------------------------------------------------------------- /.idea/runConfigurations.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 12 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/binding/GlideAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.binding 2 | 3 | import android.widget.ImageView 4 | import androidx.databinding.BindingAdapter 5 | import com.bumptech.glide.Glide 6 | import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions 7 | 8 | @BindingAdapter("imageFromUrl") 9 | fun bindImageFromUrl(view: ImageView, imageUrl: String?) { 10 | if (!imageUrl.isNullOrEmpty()) { 11 | Glide.with(view.context) 12 | .load(imageUrl) 13 | .transition(DrawableTransitionOptions.withCrossFade()) 14 | .into(view) 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/src/main/res/layout/activity_main.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/entities/ProductEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.entities 2 | 3 | import androidx.room.ColumnInfo 4 | import androidx.room.Entity 5 | import androidx.room.PrimaryKey 6 | import com.google.gson.annotations.SerializedName 7 | import com.lucasmontano.shopping.utilities.DATABASE_TABLE_PRODUCTS 8 | 9 | @Entity(tableName = DATABASE_TABLE_PRODUCTS) 10 | data class ProductEntity( 11 | @PrimaryKey 12 | @ColumnInfo(name = "id") 13 | @SerializedName("id") 14 | val productId: String, 15 | val name: String, 16 | val type: String, 17 | val imageUrl: String = "", 18 | val price: Price 19 | ) { 20 | 21 | override fun toString() = name 22 | 23 | data class Price(val value: Double, val currency: String) 24 | } 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/adapters/AdapterDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.adapters 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.RecyclerView 5 | 6 | abstract class AdapterDelegate { 7 | 8 | abstract fun getViewType(): Int 9 | 10 | abstract fun isForViewType(items: List, position: Int): Boolean 11 | 12 | abstract fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder 13 | 14 | abstract fun onBindViewHolder( 15 | item: T, 16 | holder: RecyclerView.ViewHolder, 17 | position: Int 18 | ) 19 | 20 | abstract fun onBindViewHolder( 21 | item: T, 22 | holder: RecyclerView.ViewHolder, 23 | position: Int, 24 | payloads: MutableList 25 | ) 26 | } 27 | -------------------------------------------------------------------------------- /app/proguard-rules.pro: -------------------------------------------------------------------------------- 1 | # Add project specific ProGuard rules here. 2 | # You can control the set of applied configuration files using the 3 | # proguardFiles setting in build.gradle. 4 | # 5 | # For more details, see 6 | # http://developer.android.com/guide/developing/tools/proguard.html 7 | 8 | # If your project uses WebView with JS, uncomment the following 9 | # and specify the fully qualified class name to the JavaScript interface 10 | # class: 11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview { 12 | # public *; 13 | #} 14 | 15 | # Uncomment this to preserve the line number information for 16 | # debugging stack traces. 17 | #-keepattributes SourceFile,LineNumberTable 18 | 19 | # If you keep the line number information, uncomment this to 20 | # hide the original source file name. 21 | #-renamesourcefileattribute SourceFile -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_cart_24.xml: -------------------------------------------------------------------------------- 1 | 2 | 8 | 11 | 12 | -------------------------------------------------------------------------------- /.idea/gradle.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 20 | 21 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_remove_cart_24.xml: -------------------------------------------------------------------------------- 1 | 7 | 10 | 11 | -------------------------------------------------------------------------------- /app/src/main/AndroidManifest.xml: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/domain/ProductDomainModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.domain 2 | 3 | data class ProductDomainModel( 4 | override val id: String, 5 | override val name: String, 6 | override val price: Double, 7 | override val currency: String, 8 | override val color: String, 9 | override val imageUrl: String 10 | ) : HasBasicProductAttr 11 | 12 | interface HasBasicProductAttr { 13 | val id: String 14 | val name: String 15 | val price: Double 16 | val currency: String 17 | val color: String 18 | val imageUrl: String 19 | } 20 | 21 | interface HasSeats { 22 | val numberOfSeats: Int 23 | } 24 | 25 | interface HasMaterial { 26 | val material: String 27 | } 28 | 29 | data class ProductWithSeats(override val numberOfSeats: Int) : HasSeats 30 | 31 | data class ProductWithMaterial(override val material: String) : HasMaterial 32 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/entities/ProductShoppingEntity.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.entities 2 | 3 | import androidx.room.* 4 | import com.lucasmontano.shopping.utilities.DATABASE_TABLE_CART 5 | import java.util.* 6 | 7 | @Entity( 8 | tableName = DATABASE_TABLE_CART, 9 | foreignKeys = [ 10 | ForeignKey( 11 | entity = ProductEntity::class, 12 | parentColumns = ["id"], 13 | childColumns = ["product_id"] 14 | ) 15 | ], 16 | indices = [Index("product_id")] 17 | ) 18 | data class ProductShoppingEntity( 19 | @ColumnInfo(name = "product_id") val productId: String, 20 | @ColumnInfo(name = "quantity") val quantity: Int, 21 | @ColumnInfo(name = "added_cart_date") val addedToCartDate: Calendar = Calendar.getInstance() 22 | ) { 23 | @PrimaryKey(autoGenerate = true) 24 | @ColumnInfo(name = "id") 25 | var productShoppingId: Long = 0 26 | } 27 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/mappers/ModelMappers.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.mappers 2 | 3 | import com.lucasmontano.shopping.data.domain.* 4 | import com.lucasmontano.shopping.data.entities.ProductEntity 5 | 6 | fun ProductEntity.toDomainModel(): HasBasicProductAttr? { 7 | val productAttr = ProductDomainModel( 8 | id = productId, 9 | name = name, 10 | imageUrl = imageUrl, 11 | price = 0.0, 12 | currency = "", 13 | color = "" 14 | ) 15 | return when (type) { 16 | "chair" -> { 17 | ChairDomainModel( 18 | productAttr = productAttr, 19 | materialAttr = ProductWithMaterial("") 20 | ) 21 | } 22 | "couch" -> { 23 | CouchDomainModel( 24 | productAttr = productAttr, 25 | seatsAttr = ProductWithSeats(2) 26 | ) 27 | } 28 | else -> null 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/domain/CouchDomainModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.domain 2 | 3 | data class CouchDomainModel( 4 | private val productAttr: HasBasicProductAttr, 5 | private val seatsAttr: HasSeats, 6 | ) : HasBasicProductAttr by productAttr, HasSeats by seatsAttr { 7 | 8 | override val id = productAttr.id 9 | override val name = productAttr.name 10 | 11 | override fun equals(other: Any?): Boolean { 12 | if (this === other) return true 13 | if (javaClass != other?.javaClass) return false 14 | 15 | other as HasBasicProductAttr 16 | 17 | if (id != other.id) return false 18 | if (name != other.name) return false 19 | 20 | return true 21 | } 22 | 23 | // I need to Read Effective Java to understand that better :) 24 | override fun hashCode(): Int { 25 | var result = id.hashCode() 26 | result = 31 * result + name.hashCode() 27 | return result 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/domain/ChairDomainModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.domain 2 | 3 | data class ChairDomainModel( 4 | private val productAttr: HasBasicProductAttr, 5 | private val materialAttr: HasMaterial 6 | ) : HasBasicProductAttr by productAttr, HasMaterial by materialAttr { 7 | 8 | override val id = productAttr.id 9 | override val name = productAttr.name 10 | 11 | override fun equals(other: Any?): Boolean { 12 | if (this === other) return true 13 | if (javaClass != other?.javaClass) return false 14 | 15 | other as HasBasicProductAttr 16 | 17 | if (id != other.id) return false 18 | if (name != other.name) return false 19 | 20 | return true 21 | } 22 | 23 | // I need to Read Effective Java to understand that better :) 24 | override fun hashCode(): Int { 25 | var result = id.hashCode() 26 | result = 31 * result + name.hashCode() 27 | return result 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /app/src/main/res/layout/fragment_product_list.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 8 | 9 | 13 | 14 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/dao/ProductDao.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.dao 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.Dao 5 | import androidx.room.Insert 6 | import androidx.room.OnConflictStrategy 7 | import androidx.room.Query 8 | import com.lucasmontano.shopping.data.entities.ProductEntity 9 | import com.lucasmontano.shopping.utilities.DATABASE_TABLE_PRODUCTS 10 | 11 | @Dao 12 | interface ProductDao { 13 | @Query("SELECT * FROM $DATABASE_TABLE_PRODUCTS ORDER BY name") 14 | fun getAllProducts(): LiveData> 15 | 16 | @Query("SELECT * FROM $DATABASE_TABLE_PRODUCTS WHERE type = :type ORDER BY name") 17 | fun getAllProductsByType(type: String): LiveData> 18 | 19 | @Query("SELECT * FROM $DATABASE_TABLE_PRODUCTS WHERE id = :productId") 20 | fun getProduct(productId: String): LiveData 21 | 22 | @Insert(onConflict = OnConflictStrategy.REPLACE) 23 | suspend fun insertAll(productList: List) 24 | } 25 | -------------------------------------------------------------------------------- /.idea/jarRepositories.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 9 | 10 | 14 | 15 | 19 | 20 | 24 | 25 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/Converters.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data 2 | 3 | import androidx.room.TypeConverter 4 | import com.lucasmontano.shopping.data.entities.ProductEntity 5 | import org.json.JSONObject 6 | import java.util.* 7 | 8 | /** 9 | * Type converters to allow Room to reference complex data types. 10 | */ 11 | class Converters { 12 | @TypeConverter 13 | fun calendarToDatestamp(calendar: Calendar): Long = calendar.timeInMillis 14 | 15 | @TypeConverter 16 | fun datestampToCalendar(value: Long): Calendar = 17 | Calendar.getInstance().apply { timeInMillis = value } 18 | 19 | @TypeConverter 20 | fun priceToJsonStringify(value: ProductEntity.Price): String = JSONObject().apply { 21 | put("value", value.value) 22 | put("currency", value.currency) 23 | }.toString() 24 | 25 | @TypeConverter 26 | fun jsonStringifyToPrice(value: String): ProductEntity.Price { 27 | val priceJson = JSONObject(value) 28 | return ProductEntity.Price( 29 | value = priceJson.getDouble("value"), 30 | currency = priceJson.getString("currency") 31 | ) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/repositories/ProductRepository.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.repositories 2 | 3 | import androidx.lifecycle.Transformations 4 | import com.lucasmontano.shopping.data.dao.ProductDao 5 | import com.lucasmontano.shopping.data.domain.HasBasicProductAttr 6 | import com.lucasmontano.shopping.data.entities.ProductEntity 7 | import com.lucasmontano.shopping.data.mappers.toDomainModel 8 | import javax.inject.Inject 9 | import javax.inject.Singleton 10 | 11 | @Singleton 12 | class ProductRepository @Inject constructor(private val productDao: ProductDao) { 13 | 14 | fun getAllProducts(type: String? = null) = type?.run { 15 | Transformations.map(productDao.getAllProductsByType(this)) { products -> 16 | products.mapToDomainModel() 17 | } 18 | } ?: Transformations.map(productDao.getAllProducts()) { products -> 19 | products.mapToDomainModel() 20 | } 21 | 22 | fun getProduct(productId: String) = productDao.getProduct(productId) 23 | 24 | private fun List.mapToDomainModel(): List { 25 | return this.mapNotNull { 26 | it.toDomainModel() 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | # Project-wide Gradle settings. 2 | # IDE (e.g. Android Studio) users: 3 | # Gradle settings configured through the IDE *will override* 4 | # any settings specified in this file. 5 | # For more details on how to configure your build environment visit 6 | # http://www.gradle.org/docs/current/userguide/build_environment.html 7 | # Specifies the JVM arguments used for the daemon process. 8 | # The setting is particularly useful for tweaking memory settings. 9 | org.gradle.jvmargs=-Xmx2048m 10 | # When configured, Gradle will run in incubating parallel mode. 11 | # This option should only be used with decoupled projects. More details, visit 12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects 13 | # org.gradle.parallel=true 14 | # AndroidX package structure to make it clearer which packages are bundled with the 15 | # Android operating system, and which are packaged with your app"s APK 16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn 17 | android.useAndroidX=true 18 | # Automatically convert third-party libraries to use AndroidX 19 | android.enableJetifier=true 20 | # Kotlin code style for this project: "official" or "obsolete": 21 | kotlin.code.style=official -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lucasmontano/shopping/data/InitDatabaseWorkerTest.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data 2 | 3 | import android.content.Context 4 | import androidx.test.core.app.ApplicationProvider 5 | import androidx.work.ListenableWorker.Result 6 | import androidx.work.WorkManager 7 | import androidx.work.testing.TestListenableWorkerBuilder 8 | import com.lucasmontano.shopping.workers.InitDatabaseWorker 9 | import org.hamcrest.CoreMatchers.`is` 10 | import org.junit.Assert.assertThat 11 | import org.junit.Before 12 | import org.junit.Test 13 | import org.junit.runner.RunWith 14 | import org.junit.runners.JUnit4 15 | 16 | @RunWith(JUnit4::class) 17 | class InitDatabaseWorkerTest { 18 | private lateinit var context: Context 19 | private lateinit var workManager: WorkManager 20 | 21 | @Before 22 | fun setup() { 23 | context = ApplicationProvider.getApplicationContext() 24 | workManager = WorkManager.getInstance(context) 25 | } 26 | 27 | @Test 28 | fun testRefreshMainDataWork() { 29 | val worker = TestListenableWorkerBuilder(context).build() 30 | val result = worker.startWork().get() 31 | assertThat(result, `is`(Result.success())) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lucasmontano/shopping/MainTestRunner.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.lucasmontano.shopping 18 | 19 | import android.app.Application 20 | import android.content.Context 21 | import androidx.test.runner.AndroidJUnitRunner 22 | import dagger.hilt.android.testing.HiltTestApplication 23 | 24 | // A custom runner to set up the instrumented application class for tests. 25 | class MainTestRunner : AndroidJUnitRunner() { 26 | 27 | override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application { 28 | return super.newApplication(cl, HiltTestApplication::class.java.name, context) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/viewmodels/CartViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.viewmodels 2 | 3 | import androidx.hilt.lifecycle.ViewModelInject 4 | import androidx.lifecycle.* 5 | import com.lucasmontano.shopping.data.entities.ProductCartRelation 6 | import com.lucasmontano.shopping.data.repositories.CartRepository 7 | import kotlinx.coroutines.launch 8 | 9 | class CartViewModel @ViewModelInject internal constructor( 10 | private val cartRepository: CartRepository 11 | ) : ViewModel() { 12 | 13 | private val cart: LiveData> = cartRepository.getAllProducts() 14 | 15 | val uiState: LiveData = Transformations.switchMap(cart) { 16 | // TODO map to domain model 17 | var total = 0.0 18 | val currency = cart.value?.firstOrNull()?.product?.price?.currency ?: "EUR" 19 | cart.value?.forEach { 20 | total += it.product.price.value 21 | } 22 | MutableLiveData(ViewState("$currency $$total")) 23 | } 24 | 25 | fun addToCart(productId: String) = viewModelScope.launch { 26 | cartRepository.addProduct(productId) 27 | } 28 | 29 | fun removeFromCart(productId: String) = viewModelScope.launch { 30 | cartRepository.removeProduct(productId) 31 | } 32 | 33 | data class ViewState(val total: String) 34 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/dao/CartDao.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.dao 2 | 3 | import androidx.lifecycle.LiveData 4 | import androidx.room.* 5 | import com.lucasmontano.shopping.data.entities.ProductCartRelation 6 | import com.lucasmontano.shopping.data.entities.ProductShoppingEntity 7 | import com.lucasmontano.shopping.utilities.DATABASE_TABLE_CART 8 | import com.lucasmontano.shopping.utilities.DATABASE_TABLE_PRODUCTS 9 | 10 | @Dao 11 | interface CartDao { 12 | 13 | @Query("SELECT * FROM $DATABASE_TABLE_CART") 14 | fun getProductsShopping(): LiveData> 15 | 16 | @Query("SELECT * FROM $DATABASE_TABLE_CART WHERE product_id = :productId LIMIT 1") 17 | fun getProductShopping(productId: String): ProductShoppingEntity? 18 | 19 | @Query("SELECT EXISTS(SELECT 1 FROM $DATABASE_TABLE_CART WHERE product_id = :productId LIMIT 1)") 20 | fun isAddedToCart(productId: String): LiveData 21 | 22 | @Transaction 23 | @Query("SELECT * FROM $DATABASE_TABLE_PRODUCTS WHERE id IN (SELECT DISTINCT(product_id) FROM $DATABASE_TABLE_CART)") 24 | fun getAllProducts(): LiveData> 25 | 26 | @Insert 27 | suspend fun addProduct(productShoppingEntity: ProductShoppingEntity): Long 28 | 29 | @Delete 30 | suspend fun removeProduct(productShoppingEntity: ProductShoppingEntity) 31 | 32 | @Update 33 | suspend fun updateProduct(productShoppingEntity: ProductShoppingEntity) 34 | } 35 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/adapters/AdapterDelegatesManager.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.adapters 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.RecyclerView 5 | 6 | class AdapterDelegatesManager { 7 | 8 | private val delegates: HashMap> = HashMap() 9 | 10 | fun addDelegate(delegate: AdapterDelegate) { 11 | delegates[delegate.getViewType()] = delegate 12 | } 13 | 14 | fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 15 | return delegateForViewType(viewType)?.onCreateViewHolder(parent, viewType) 16 | ?: throw Exception("No Delegate Found for ViewType $viewType") 17 | } 18 | 19 | fun onBindViewHolder(item: T, holder: RecyclerView.ViewHolder, position: Int) { 20 | delegateForViewType(holder.itemViewType)?.onBindViewHolder(item, holder, position) 21 | ?: throw Exception("No Delegate Found for ViewType ${holder.itemViewType}") 22 | } 23 | 24 | fun onBindViewHolder( 25 | item: T, 26 | holder: RecyclerView.ViewHolder, 27 | position: Int, 28 | payload: MutableList 29 | ) { 30 | delegateForViewType(holder.itemViewType)?.onBindViewHolder(item, holder, position, payload) 31 | ?: throw Exception("No Delegate Found for ViewType ${holder.itemViewType}") 32 | } 33 | 34 | private fun delegateForViewType(viewType: Int): AdapterDelegate? = delegates.get(viewType) 35 | 36 | } 37 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/repositories/CartRepository.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data.repositories 2 | 3 | import com.lucasmontano.shopping.data.dao.CartDao 4 | import com.lucasmontano.shopping.data.entities.ProductShoppingEntity 5 | import kotlinx.coroutines.Dispatchers 6 | import kotlinx.coroutines.withContext 7 | import javax.inject.Inject 8 | import javax.inject.Singleton 9 | 10 | @Singleton 11 | class CartRepository @Inject constructor( 12 | private val cartDao: CartDao 13 | ) { 14 | 15 | suspend fun addProduct(productId: String, quantity: Int = 1) { 16 | withContext(Dispatchers.IO) { 17 | cartDao.getProductShopping(productId)?.let { shopping -> 18 | cartDao.updateProduct(shopping.copy(quantity = shopping.quantity - 2)) 19 | } ?: cartDao.addProduct(ProductShoppingEntity(productId, quantity)) 20 | } 21 | } 22 | 23 | suspend fun removeProduct(productId: String) { 24 | withContext(Dispatchers.IO) { 25 | cartDao.getProductShopping(productId)?.let { shopping -> 26 | if (shopping.quantity == 1) { 27 | cartDao.removeProduct(shopping) 28 | } else { 29 | cartDao.updateProduct(shopping.copy(quantity = shopping.quantity - 1)) 30 | } 31 | } 32 | } 33 | } 34 | 35 | fun isOnCart(productId: String) = 36 | cartDao.isAddedToCart(productId) 37 | 38 | fun getAllProducts() = cartDao.getAllProducts() 39 | } 40 | -------------------------------------------------------------------------------- /app/src/test/java/com/lucasmontano/shopping/TestUtilities.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.lucasmontano.shopping 18 | 19 | import androidx.lifecycle.LiveData 20 | import java.util.concurrent.CountDownLatch 21 | import java.util.concurrent.TimeUnit 22 | 23 | /** 24 | * Helper method for testing LiveData objects, from 25 | * https://github.com/googlesamples/android-architecture-components. 26 | * 27 | * Get the value from a LiveData object. We're waiting for LiveData to emit, for 2 seconds. 28 | * Once we got a notification via onChanged, we stop observing. 29 | */ 30 | @Throws(InterruptedException::class) 31 | fun getValueUnitTest(liveData: LiveData): T { 32 | val data = arrayOfNulls(1) 33 | val latch = CountDownLatch(1) 34 | liveData.observeForever { o -> 35 | data[0] = o 36 | latch.countDown() 37 | } 38 | latch.await(2, TimeUnit.SECONDS) 39 | 40 | @Suppress("UNCHECKED_CAST") 41 | return data[0] as T 42 | } 43 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lucasmontano/shopping/utilities/LiveDataTestUtil.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2018 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.lucasmontano.shopping.utilities 18 | 19 | import androidx.lifecycle.LiveData 20 | import java.util.concurrent.CountDownLatch 21 | import java.util.concurrent.TimeUnit 22 | 23 | /** 24 | * Helper method for testing LiveData objects, from 25 | * https://github.com/googlesamples/android-architecture-components. 26 | * 27 | * Get the value from a LiveData object. We're waiting for LiveData to emit, for 2 seconds. 28 | * Once we got a notification via onChanged, we stop observing. 29 | */ 30 | @Throws(InterruptedException::class) 31 | fun getValue(liveData: LiveData): T { 32 | val data = arrayOfNulls(1) 33 | val latch = CountDownLatch(1) 34 | liveData.observeForever { o -> 35 | data[0] = o 36 | latch.countDown() 37 | } 38 | latch.await(2, TimeUnit.SECONDS) 39 | 40 | @Suppress("UNCHECKED_CAST") 41 | return data[0] as T 42 | } 43 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/di/DatabaseModule.kt: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * https://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package com.lucasmontano.shopping.di 18 | 19 | import android.content.Context 20 | import com.lucasmontano.shopping.data.AppDatabase 21 | import com.lucasmontano.shopping.data.dao.CartDao 22 | import com.lucasmontano.shopping.data.dao.ProductDao 23 | import dagger.Module 24 | import dagger.Provides 25 | import dagger.hilt.InstallIn 26 | import dagger.hilt.android.components.ApplicationComponent 27 | import dagger.hilt.android.qualifiers.ApplicationContext 28 | import javax.inject.Singleton 29 | 30 | @InstallIn(ApplicationComponent::class) 31 | @Module 32 | class DatabaseModule { 33 | 34 | @Singleton 35 | @Provides 36 | fun provideAppDatabase(@ApplicationContext context: Context): AppDatabase { 37 | return AppDatabase.getInstance(context) 38 | } 39 | 40 | @Provides 41 | fun provideProductDao(appDatabase: AppDatabase): ProductDao { 42 | return appDatabase.productDao() 43 | } 44 | 45 | @Provides 46 | fun provideCartDao(appDatabase: AppDatabase): CartDao { 47 | return appDatabase.cartDao() 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/src/main/res/drawable-v24/ic_launcher_foreground.xml: -------------------------------------------------------------------------------- 1 | 7 | 8 | 9 | 15 | 18 | 21 | 22 | 23 | 24 | 30 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/workers/InitDatabaseWorker.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.workers 2 | 3 | import android.content.Context 4 | import android.util.Log 5 | import androidx.work.CoroutineWorker 6 | import androidx.work.WorkerParameters 7 | import com.google.gson.Gson 8 | import com.google.gson.reflect.TypeToken 9 | import com.google.gson.stream.JsonReader 10 | import com.lucasmontano.shopping.data.AppDatabase 11 | import com.lucasmontano.shopping.data.entities.ProductEntity 12 | import com.lucasmontano.shopping.utilities.DATA_FILENAME 13 | import kotlinx.coroutines.Dispatchers 14 | import kotlinx.coroutines.coroutineScope 15 | import kotlinx.coroutines.withContext 16 | 17 | class InitDatabaseWorker( 18 | context: Context, 19 | workerParams: WorkerParameters 20 | ) : CoroutineWorker(context, workerParams) { 21 | 22 | override suspend fun doWork(): Result = coroutineScope { 23 | try { 24 | val productsWrapper: ProductsWrapper = withContext(Dispatchers.IO) { 25 | applicationContext.assets.open(DATA_FILENAME).use { inputStream -> 26 | JsonReader(inputStream.reader()).use { jsonReader -> 27 | val productType = object : TypeToken() {}.type 28 | Gson().fromJson(jsonReader, productType) 29 | } 30 | } 31 | } 32 | 33 | val database = AppDatabase.getInstance(applicationContext) 34 | database.productDao().insertAll(productsWrapper.products) 35 | 36 | Result.success() 37 | } catch (ex: Exception) { 38 | Log.e(TAG, "Error initializing database", ex) 39 | Result.failure() 40 | } 41 | } 42 | 43 | data class ProductsWrapper(val products: List) 44 | 45 | companion object { 46 | private val TAG = this::class.simpleName 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/data/AppDatabase.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data 2 | 3 | import android.content.Context 4 | import androidx.room.Database 5 | import androidx.room.Room 6 | import androidx.room.RoomDatabase 7 | import androidx.room.TypeConverters 8 | import androidx.sqlite.db.SupportSQLiteDatabase 9 | import androidx.work.OneTimeWorkRequestBuilder 10 | import androidx.work.WorkManager 11 | import com.lucasmontano.shopping.data.dao.CartDao 12 | import com.lucasmontano.shopping.data.dao.ProductDao 13 | import com.lucasmontano.shopping.data.entities.ProductEntity 14 | import com.lucasmontano.shopping.data.entities.ProductShoppingEntity 15 | import com.lucasmontano.shopping.utilities.DATABASE_NAME 16 | import com.lucasmontano.shopping.workers.InitDatabaseWorker 17 | 18 | @Database( 19 | entities = [ProductShoppingEntity::class, ProductEntity::class], 20 | version = 1, 21 | exportSchema = false 22 | ) 23 | @TypeConverters(Converters::class) 24 | abstract class AppDatabase : RoomDatabase() { 25 | 26 | abstract fun productDao(): ProductDao 27 | abstract fun cartDao(): CartDao 28 | 29 | companion object { 30 | 31 | @Volatile 32 | private var instance: AppDatabase? = null 33 | 34 | fun getInstance(context: Context): AppDatabase { 35 | return instance ?: synchronized(this) { 36 | instance ?: buildDatabase(context).also { instance = it } 37 | } 38 | } 39 | 40 | private fun buildDatabase(context: Context): AppDatabase { 41 | return Room.databaseBuilder(context, AppDatabase::class.java, DATABASE_NAME) 42 | .addCallback( 43 | object : RoomDatabase.Callback() { 44 | override fun onCreate(db: SupportSQLiteDatabase) { 45 | super.onCreate(db) 46 | val request = OneTimeWorkRequestBuilder().build() 47 | WorkManager.getInstance(context).enqueue(request) 48 | } 49 | } 50 | ) 51 | .build() 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_basic_product.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 14 | 15 | 18 | 19 | 20 | 27 | 28 | 35 | 36 | 45 | 46 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/adapters/DelegationAdapter.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.adapters 2 | 3 | import android.view.ViewGroup 4 | import androidx.recyclerview.widget.DiffUtil 5 | import androidx.recyclerview.widget.ListAdapter 6 | import androidx.recyclerview.widget.RecyclerView 7 | import com.lucasmontano.shopping.ui.models.BasicProductUiModel 8 | import com.lucasmontano.shopping.ui.models.TiledUiModelProduct 9 | 10 | class DelegationAdapter(diffCallback: DiffUtil.ItemCallback) : 11 | ListAdapter(diffCallback), BindableAdapter> { 12 | 13 | private val adapterDelegatesManager: AdapterDelegatesManager = AdapterDelegatesManager() 14 | 15 | constructor( 16 | diffCallback: DiffUtil.ItemCallback, 17 | vararg delegate: AdapterDelegate 18 | ) : this(diffCallback) { 19 | delegate.forEach { 20 | adapterDelegatesManager.addDelegate(it) 21 | } 22 | } 23 | 24 | fun addDelegates(vararg delegate: AdapterDelegate) { 25 | delegate.forEach { 26 | adapterDelegatesManager.addDelegate(it) 27 | } 28 | } 29 | 30 | override fun getItemViewType(position: Int): Int { 31 | return when (this.getItem(position)) { 32 | is TiledUiModelProduct -> TiledUiModelProduct::class.hashCode() 33 | is BasicProductUiModel -> BasicProductUiModel::class.hashCode() 34 | else -> super.getItemViewType(position) 35 | } 36 | } 37 | 38 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 39 | return adapterDelegatesManager.onCreateViewHolder(parent, viewType) 40 | } 41 | 42 | override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) { 43 | adapterDelegatesManager.onBindViewHolder(getItem(position), holder, position) 44 | } 45 | 46 | override fun onBindViewHolder( 47 | holder: RecyclerView.ViewHolder, 48 | position: Int, 49 | payloads: MutableList 50 | ) { 51 | adapterDelegatesManager.onBindViewHolder(getItem(position), holder, position, payloads) 52 | } 53 | 54 | override fun setData(data: List?) { 55 | submitList(data) 56 | } 57 | } 58 | 59 | interface BindableAdapter { 60 | fun setData(data: T?) 61 | } 62 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/viewmodels/ProductListViewModel.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.viewmodels 2 | 3 | import androidx.hilt.lifecycle.ViewModelInject 4 | import androidx.lifecycle.LiveData 5 | import androidx.lifecycle.MutableLiveData 6 | import androidx.lifecycle.Transformations 7 | import androidx.lifecycle.ViewModel 8 | import com.lucasmontano.shopping.data.domain.HasBasicProductAttr 9 | import com.lucasmontano.shopping.data.repositories.CartRepository 10 | import com.lucasmontano.shopping.data.repositories.ProductRepository 11 | import com.lucasmontano.shopping.ui.models.BasicProductUiModel 12 | import com.lucasmontano.shopping.ui.models.ProductUiModel 13 | import com.lucasmontano.shopping.ui.models.TiledUiModelProduct 14 | 15 | class ProductListViewModel @ViewModelInject internal constructor( 16 | productRepository: ProductRepository 17 | ) : ViewModel() { 18 | 19 | private val filter = MutableLiveData() 20 | 21 | private val products: LiveData> = Transformations.switchMap(filter) { 22 | when (filter.value) { 23 | is FilterState.Type -> { 24 | it as FilterState.Type 25 | Transformations.map(productRepository.getAllProducts(it.type)) { products -> 26 | products.toUiModel() 27 | } 28 | } 29 | else -> Transformations.map(productRepository.getAllProducts()) { products -> 30 | products.toUiModel() 31 | } 32 | } 33 | } 34 | 35 | val uiState: LiveData = Transformations.switchMap(products) { 36 | MutableLiveData(ViewState(it)) 37 | } 38 | 39 | init { 40 | filter.postValue(FilterState.Empty) 41 | } 42 | 43 | fun clearFilter() = filter.postValue(FilterState.Empty) 44 | 45 | fun filterByType(type: String) = filter.postValue(FilterState.Type(type)) 46 | 47 | private fun List.toUiModel(): List { 48 | return this.mapIndexed { index, productEntity -> 49 | when (index) { 50 | 0 -> TiledUiModelProduct( 51 | productEntity.id, 52 | productEntity.name, 53 | productEntity.imageUrl 54 | ) 55 | else -> BasicProductUiModel(productEntity.id, productEntity.name) 56 | } 57 | } 58 | } 59 | 60 | data class ViewState(val products: List) 61 | 62 | private sealed class FilterState { 63 | object Empty : FilterState() 64 | data class Type(val type: String) : FilterState() 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /app/src/main/res/layout/item_tiled_product.xml: -------------------------------------------------------------------------------- 1 | 2 | 4 | 5 | 6 | 7 | 10 | 11 | 14 | 15 | 18 | 19 | 20 | 27 | 28 | 34 | 35 | 42 | 43 | 52 | 53 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/adapters/ProductAdapterDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.adapters 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.databinding.DataBindingUtil 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.lucasmontano.shopping.R 9 | import com.lucasmontano.shopping.databinding.ItemBasicProductBinding 10 | import com.lucasmontano.shopping.ui.models.BasicProductUiModel 11 | import com.lucasmontano.shopping.ui.models.ProductUiModel 12 | 13 | internal class ProductAdapterDelegate( 14 | private val viewLifecycleOwner: LifecycleOwner, 15 | val addToCartListener: (value: String) -> Unit, 16 | val removeFromCartListener: (value: String) -> Unit 17 | ) : AdapterDelegate() { 18 | 19 | override fun getViewType() = BasicProductUiModel::class.hashCode() 20 | 21 | override fun isForViewType(items: List, position: Int): Boolean { 22 | return items[position] is BasicProductUiModel 23 | } 24 | 25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 26 | val binding: ItemBasicProductBinding = DataBindingUtil.inflate( 27 | LayoutInflater.from(parent.context), 28 | R.layout.item_basic_product, 29 | parent, 30 | false 31 | ) 32 | return ViewHolder(binding) 33 | } 34 | 35 | inner class ViewHolder(private val binding: ItemBasicProductBinding) : 36 | RecyclerView.ViewHolder(binding.root) { 37 | 38 | init { 39 | binding.setAddProductListener { 40 | binding.item?.let { product -> 41 | addToCartListener.invoke(product.id) 42 | } 43 | } 44 | binding.setRemoveProductListener { 45 | binding.item?.let { product -> 46 | removeFromCartListener.invoke(product.id) 47 | } 48 | } 49 | } 50 | 51 | fun bind(product: BasicProductUiModel) { 52 | binding.apply { 53 | item = product 54 | executePendingBindings() 55 | } 56 | } 57 | } 58 | 59 | override fun onBindViewHolder( 60 | item: ProductUiModel, 61 | holder: RecyclerView.ViewHolder, 62 | position: Int, 63 | payloads: MutableList 64 | ) { 65 | (holder as ViewHolder).bind(item as BasicProductUiModel) 66 | } 67 | 68 | override fun onBindViewHolder( 69 | item: ProductUiModel, 70 | holder: RecyclerView.ViewHolder, 71 | position: Int 72 | ) { 73 | (holder as ViewHolder).bind(item as BasicProductUiModel) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/adapters/TileProductAdapterDelegate.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui.adapters 2 | 3 | import android.view.LayoutInflater 4 | import android.view.ViewGroup 5 | import androidx.databinding.DataBindingUtil 6 | import androidx.lifecycle.LifecycleOwner 7 | import androidx.recyclerview.widget.RecyclerView 8 | import com.lucasmontano.shopping.R 9 | import com.lucasmontano.shopping.databinding.ItemTiledProductBinding 10 | import com.lucasmontano.shopping.ui.models.ProductUiModel 11 | import com.lucasmontano.shopping.ui.models.TiledUiModelProduct 12 | 13 | internal class TileProductAdapterDelegate( 14 | private val viewLifecycleOwner: LifecycleOwner, 15 | val addToCartListener: (value: String) -> Unit, 16 | val removeFromCartListener: (value: String) -> Unit 17 | ) : AdapterDelegate() { 18 | 19 | override fun getViewType() = TiledUiModelProduct::class.hashCode() 20 | 21 | override fun isForViewType(items: List, position: Int): Boolean { 22 | return items[position] is TiledUiModelProduct 23 | } 24 | 25 | override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder { 26 | val binding: ItemTiledProductBinding = DataBindingUtil.inflate( 27 | LayoutInflater.from(parent.context), 28 | R.layout.item_tiled_product, 29 | parent, 30 | false 31 | ) 32 | return ViewHolder(binding) 33 | } 34 | 35 | inner class ViewHolder(private val binding: ItemTiledProductBinding) : 36 | RecyclerView.ViewHolder(binding.root) { 37 | 38 | init { 39 | binding.setAddProductListener { 40 | binding.item?.let { product -> 41 | addToCartListener.invoke(product.id) 42 | } 43 | } 44 | binding.setRemoveProductListener { 45 | binding.item?.let { product -> 46 | removeFromCartListener.invoke(product.id) 47 | } 48 | } 49 | } 50 | 51 | fun bind(product: TiledUiModelProduct) { 52 | binding.apply { 53 | item = product 54 | executePendingBindings() 55 | } 56 | } 57 | } 58 | 59 | override fun onBindViewHolder( 60 | item: ProductUiModel, 61 | holder: RecyclerView.ViewHolder, 62 | position: Int, 63 | payloads: MutableList 64 | ) { 65 | (holder as ViewHolder).bind(item as TiledUiModelProduct) 66 | } 67 | 68 | override fun onBindViewHolder( 69 | item: ProductUiModel, 70 | holder: RecyclerView.ViewHolder, 71 | position: Int 72 | ) { 73 | (holder as ViewHolder).bind(item as TiledUiModelProduct) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lucasmontano/shopping/data/ProductDaoTest.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.data 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.room.Room 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import com.lucasmontano.shopping.data.dao.ProductDao 8 | import com.lucasmontano.shopping.data.entities.ProductEntity 9 | import com.lucasmontano.shopping.utilities.getValue 10 | import kotlinx.coroutines.runBlocking 11 | import org.hamcrest.Matchers.equalTo 12 | import org.junit.After 13 | import org.junit.Assert.assertThat 14 | import org.junit.Before 15 | import org.junit.Rule 16 | import org.junit.Test 17 | import org.junit.runner.RunWith 18 | 19 | @RunWith(AndroidJUnit4::class) 20 | class ProductDaoTest { 21 | private lateinit var database: AppDatabase 22 | private lateinit var dao: ProductDao 23 | 24 | private val productA = ProductEntity( 25 | "1", 26 | "Name A", 27 | "Type 1", 28 | "URL", 29 | ProductEntity.Price(10.0, "") 30 | ) 31 | 32 | private val productB = ProductEntity( 33 | "2", 34 | "Name B", 35 | "Type 2", 36 | "URL", 37 | ProductEntity.Price(10.0, "") 38 | ) 39 | 40 | private val productC = ProductEntity( 41 | "3", 42 | "Name C", 43 | "Type 1", 44 | "URL", 45 | ProductEntity.Price(10.0, "") 46 | ) 47 | 48 | @get:Rule 49 | var instantTaskExecutorRule = InstantTaskExecutorRule() 50 | 51 | @Before 52 | fun createDb() = runBlocking { 53 | val context = InstrumentationRegistry.getInstrumentation().targetContext 54 | database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() 55 | dao = database.productDao() 56 | dao.insertAll(listOf(productB, productC, productA)) 57 | } 58 | 59 | @After 60 | fun closeDb() { 61 | database.close() 62 | } 63 | 64 | @Test 65 | fun testGetAllProducts() { 66 | val productList = getValue(dao.getAllProducts()) 67 | assertThat(productList.size, equalTo(3)) 68 | assertThat(productList[0], equalTo(productA)) 69 | assertThat(productList[1], equalTo(productB)) 70 | assertThat(productList[2], equalTo(productC)) 71 | } 72 | 73 | @Test 74 | fun testGetProductsByType() { 75 | val productList = getValue(dao.getAllProductsByType("Type 1")) 76 | assertThat(productList.size, equalTo(2)) 77 | assertThat(productList[0], equalTo(productA)) 78 | assertThat(productList[1], equalTo(productC)) 79 | } 80 | 81 | @Test 82 | fun testGetProduct() { 83 | assertThat(getValue(dao.getProduct(productA.productId)), equalTo(productA)) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /app/src/androidTest/java/com/lucasmontano/shopping/repositories/CartRepositoriesTest.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.repositories 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.room.Room 5 | import androidx.test.ext.junit.runners.AndroidJUnit4 6 | import androidx.test.platform.app.InstrumentationRegistry 7 | import com.lucasmontano.shopping.data.AppDatabase 8 | import com.lucasmontano.shopping.data.dao.CartDao 9 | import com.lucasmontano.shopping.data.dao.ProductDao 10 | import com.lucasmontano.shopping.data.entities.ProductEntity 11 | import com.lucasmontano.shopping.data.repositories.CartRepository 12 | import com.lucasmontano.shopping.utilities.getValue 13 | import kotlinx.coroutines.runBlocking 14 | import org.hamcrest.Matchers.equalTo 15 | import org.junit.After 16 | import org.junit.Assert.assertThat 17 | import org.junit.Assert.assertTrue 18 | import org.junit.Before 19 | import org.junit.Rule 20 | import org.junit.Test 21 | import org.junit.runner.RunWith 22 | 23 | @RunWith(AndroidJUnit4::class) 24 | class CartRepositoriesTest { 25 | private lateinit var database: AppDatabase 26 | private lateinit var productDao: ProductDao 27 | private lateinit var dao: CartDao 28 | private lateinit var repository: CartRepository 29 | 30 | private val productA = ProductEntity( 31 | "1", 32 | "Name A", 33 | "Type 1", 34 | "URL", 35 | ProductEntity.Price(10.0, "") 36 | ) 37 | 38 | private val productB = ProductEntity( 39 | "2", 40 | "Name B", 41 | "Type 2", 42 | "URL", 43 | ProductEntity.Price(10.0, "") 44 | ) 45 | 46 | private val productC = ProductEntity( 47 | "3", 48 | "Name C", 49 | "Type 1", 50 | "URL", 51 | ProductEntity.Price(10.0, "") 52 | ) 53 | 54 | @get:Rule 55 | var instantTaskExecutorRule = InstantTaskExecutorRule() 56 | 57 | @Before 58 | fun createDb() = runBlocking { 59 | val context = InstrumentationRegistry.getInstrumentation().targetContext 60 | database = Room.inMemoryDatabaseBuilder(context, AppDatabase::class.java).build() 61 | productDao = database.productDao() 62 | productDao.insertAll(listOf(productB, productC, productA)) 63 | 64 | dao = database.cartDao() 65 | repository = CartRepository(dao) 66 | } 67 | 68 | @After 69 | fun closeDb() { 70 | database.close() 71 | } 72 | 73 | @Test 74 | fun testAddProductToCart() = runBlocking { 75 | repository.addProduct(productA.productId, 1) 76 | val productList = getValue(dao.getAllProducts()) 77 | assertThat(productList.size, equalTo(1)) 78 | assertThat(productList[0].product, equalTo(productA)) 79 | assertTrue(getValue(dao.isAddedToCart(productA.productId))) 80 | } 81 | 82 | @Test 83 | fun testGetAllCartProducts() = runBlocking { 84 | repository.addProduct(productA.productId, 1) 85 | repository.addProduct(productB.productId, 1) 86 | assertThat(getValue(repository.getAllProducts()).size, equalTo(2)) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /app/src/test/java/com/lucasmontano/shopping/viewmodels/ProductListViewModelTest.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.viewmodels 2 | 3 | import androidx.arch.core.executor.testing.InstantTaskExecutorRule 4 | import androidx.lifecycle.MutableLiveData 5 | import com.lucasmontano.shopping.data.domain.* 6 | import com.lucasmontano.shopping.data.repositories.ProductRepository 7 | import com.lucasmontano.shopping.getValueUnitTest 8 | import io.mockk.MockKAnnotations 9 | import io.mockk.every 10 | import io.mockk.impl.annotations.MockK 11 | import org.junit.Assert.assertTrue 12 | import org.junit.Before 13 | import org.junit.Rule 14 | import org.junit.Test 15 | 16 | class ProductListViewModelTest { 17 | 18 | @Rule 19 | @JvmField 20 | val instantExecutor = InstantTaskExecutorRule() 21 | 22 | @MockK(relaxed = true) 23 | lateinit var repository: ProductRepository 24 | 25 | private lateinit var viewModel: ProductListViewModel 26 | 27 | private val productA = ChairDomainModel( 28 | ProductDomainModel( 29 | id = "1", 30 | name = "Name A", 31 | price = 10.00, 32 | currency = "EUR", 33 | color = "blue", 34 | imageUrl = "" 35 | ), 36 | ProductWithMaterial(material = "Wood") 37 | ) 38 | private val productB = CouchDomainModel( 39 | ProductDomainModel( 40 | id = "2", 41 | name = "Name B", 42 | price = 10.00, 43 | currency = "EUR", 44 | color = "blue", 45 | imageUrl = "" 46 | ), 47 | ProductWithSeats(numberOfSeats = 2) 48 | ) 49 | private val productC = ChairDomainModel( 50 | ProductDomainModel( 51 | id = "3", 52 | name = "Name B", 53 | price = 10.00, 54 | currency = "EUR", 55 | color = "blue", 56 | imageUrl = "" 57 | ), 58 | ProductWithMaterial(material = "Wood") 59 | ) 60 | 61 | @Before 62 | fun setUp() { 63 | MockKAnnotations.init(this) 64 | 65 | viewModel = ProductListViewModel(repository) 66 | 67 | every { 68 | repository.getAllProducts(null) 69 | } returns MutableLiveData( 70 | listOf( 71 | productA, productB, productC 72 | ) 73 | ) 74 | 75 | every { 76 | repository.getAllProducts("chair") 77 | } returns MutableLiveData( 78 | listOf( 79 | productA, productC 80 | ) 81 | ) 82 | } 83 | 84 | @Test 85 | @Throws(InterruptedException::class) 86 | fun `when initializing the viewmodel, default products are listed`() { 87 | assertTrue(getValueUnitTest(viewModel.uiState).products.isNotEmpty()) 88 | } 89 | 90 | @Test 91 | @Throws(InterruptedException::class) 92 | fun `when filtering by type, repository returns the right products`() { 93 | viewModel.filterByType("chair") 94 | assertTrue(getValueUnitTest(viewModel.uiState).products.size == 2) 95 | } 96 | 97 | @Test 98 | @Throws(InterruptedException::class) 99 | fun `when clearing filter, all products are available`() { 100 | viewModel.filterByType("chair") 101 | viewModel.clearFilter() 102 | assertTrue(getValueUnitTest(viewModel.uiState).products.size == 3) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /app/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'com.android.application' 2 | apply plugin: 'kotlin-android' 3 | apply plugin: 'kotlin-android-extensions' 4 | apply plugin: 'kotlin-kapt' 5 | apply plugin: 'dagger.hilt.android.plugin' 6 | 7 | android { 8 | compileSdkVersion 29 9 | buildToolsVersion "30.0.2" 10 | 11 | buildFeatures { 12 | dataBinding true 13 | } 14 | 15 | compileOptions { 16 | sourceCompatibility JavaVersion.VERSION_1_8 17 | targetCompatibility JavaVersion.VERSION_1_8 18 | } 19 | 20 | kotlinOptions { 21 | jvmTarget = JavaVersion.VERSION_1_8.toString() 22 | } 23 | 24 | defaultConfig { 25 | applicationId "com.lucasmontano.shopping" 26 | minSdkVersion 28 27 | targetSdkVersion 29 28 | versionCode 1 29 | versionName "1.0" 30 | 31 | // TODO change to MainTestRunner when implementing ui tests 32 | testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" 33 | } 34 | 35 | buildTypes { 36 | release { 37 | minifyEnabled false 38 | proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' 39 | } 40 | } 41 | } 42 | 43 | dependencies { 44 | def room_version = "2.2.5" 45 | def work_version = "2.4.0" 46 | def assistedInject_version = "0.5.2" 47 | def hilt_version = "2.28.3-alpha" 48 | def hiltViewModel_version = "1.0.0-alpha02" 49 | def nav_version = "2.3.0" 50 | 51 | implementation fileTree(dir: "libs", include: ["*.jar"]) 52 | 53 | // Core 54 | implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" 55 | implementation 'androidx.core:core-ktx:1.3.2' 56 | implementation 'androidx.appcompat:appcompat:1.2.0' 57 | implementation 'androidx.constraintlayout:constraintlayout:2.0.2' 58 | 59 | // Navigation 60 | implementation "androidx.navigation:navigation-fragment-ktx:$nav_version" 61 | implementation "androidx.navigation:navigation-ui-ktx:$nav_version" 62 | 63 | // Tasks, Workers 64 | implementation "androidx.work:work-runtime-ktx:$work_version" 65 | 66 | // Data 67 | implementation "androidx.room:room-runtime:$room_version" 68 | kapt "androidx.room:room-compiler:$room_version" 69 | implementation "androidx.room:room-ktx:$room_version" 70 | implementation 'com.google.code.gson:gson:2.8.6' 71 | 72 | // DI 73 | compileOnly "com.squareup.inject:assisted-inject-annotations-dagger2:$assistedInject_version" 74 | kapt "com.squareup.inject:assisted-inject-processor-dagger2:$assistedInject_version" 75 | implementation "com.google.dagger:hilt-android:$hilt_version" 76 | kapt "com.google.dagger:hilt-android-compiler:$hilt_version" 77 | implementation "androidx.hilt:hilt-lifecycle-viewmodel:$hiltViewModel_version" 78 | kapt "androidx.hilt:hilt-compiler:$hiltViewModel_version" 79 | 80 | // Others 81 | implementation 'com.github.bumptech.glide:glide:4.11.0' 82 | annotationProcessor 'com.github.bumptech.glide:compiler:4.11.0' 83 | 84 | // Test helpers 85 | testImplementation "androidx.room:room-testing:$room_version" 86 | testImplementation 'junit:junit:4.13' 87 | testImplementation "io.mockk:mockk:1.10.2" 88 | testImplementation 'androidx.arch.core:core-testing:2.1.0' 89 | 90 | androidTestImplementation 'androidx.arch.core:core-testing:2.1.0' 91 | androidTestImplementation "androidx.work:work-testing:$work_version" 92 | androidTestImplementation 'androidx.test.ext:junit:1.1.2' 93 | androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' 94 | 95 | testImplementation "com.google.dagger:hilt-android-testing:$hilt_version" 96 | kaptTest "com.google.dagger:hilt-android-compiler:$hilt_version" 97 | 98 | androidTestImplementation "androidx.navigation:navigation-testing:$nav_version" 99 | androidTestImplementation "com.google.dagger:hilt-android-testing:$hilt_version" 100 | kaptAndroidTest "com.google.dagger:hilt-android-compiler:$hilt_version" 101 | } -------------------------------------------------------------------------------- /app/src/main/java/com/lucasmontano/shopping/ui/ProductListFragment.kt: -------------------------------------------------------------------------------- 1 | package com.lucasmontano.shopping.ui 2 | 3 | import android.os.Bundle 4 | import android.view.* 5 | import android.widget.TextView 6 | import androidx.fragment.app.Fragment 7 | import androidx.fragment.app.viewModels 8 | import androidx.recyclerview.widget.DiffUtil 9 | import androidx.recyclerview.widget.LinearLayoutManager 10 | import com.lucasmontano.shopping.R 11 | import com.lucasmontano.shopping.databinding.FragmentProductListBinding 12 | import com.lucasmontano.shopping.ui.adapters.DelegationAdapter 13 | import com.lucasmontano.shopping.ui.adapters.ProductAdapterDelegate 14 | import com.lucasmontano.shopping.ui.adapters.TileProductAdapterDelegate 15 | import com.lucasmontano.shopping.ui.models.ProductUiModel 16 | import com.lucasmontano.shopping.viewmodels.CartViewModel 17 | import com.lucasmontano.shopping.viewmodels.ProductListViewModel 18 | import dagger.hilt.android.AndroidEntryPoint 19 | 20 | @AndroidEntryPoint 21 | class ProductListFragment : Fragment() { 22 | 23 | private val viewModel: ProductListViewModel by viewModels() 24 | 25 | // TODO move this to a new ui component 26 | private val cartViewModel: CartViewModel by viewModels() 27 | 28 | private lateinit var binding: FragmentProductListBinding 29 | 30 | private val itemsDiff = object : DiffUtil.ItemCallback() { 31 | override fun areItemsTheSame( 32 | oldItem: ProductUiModel, 33 | newItem: ProductUiModel 34 | ): Boolean { 35 | return oldItem.id == newItem.id 36 | } 37 | 38 | override fun areContentsTheSame( 39 | oldItem: ProductUiModel, 40 | newItem: ProductUiModel 41 | ): Boolean { 42 | return oldItem.equals(newItem) 43 | } 44 | } 45 | 46 | override fun onCreateView( 47 | inflater: LayoutInflater, 48 | container: ViewGroup?, 49 | savedInstanceState: Bundle? 50 | ): View? { 51 | binding = FragmentProductListBinding.inflate(inflater, container, false) 52 | context ?: return binding.root 53 | 54 | binding.lifecycleOwner = viewLifecycleOwner 55 | 56 | val delegationAdapter = DelegationAdapter( 57 | itemsDiff, 58 | TileProductAdapterDelegate( 59 | viewLifecycleOwner, 60 | addProductToCart(), 61 | removeProductToCart() 62 | ), 63 | ProductAdapterDelegate( 64 | viewLifecycleOwner, 65 | addProductToCart(), 66 | removeProductToCart() 67 | ) 68 | ) 69 | binding.productsRecyclerView.adapter = delegationAdapter 70 | binding.productsRecyclerView.layoutManager = 71 | LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false) 72 | 73 | subscribeUiToProducts(delegationAdapter) 74 | subscribeUiToCart(binding.cartTotalTextView) 75 | 76 | setHasOptionsMenu(true) 77 | return binding.root 78 | } 79 | 80 | override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) { 81 | inflater.inflate(R.menu.menu_product_list, menu) 82 | } 83 | 84 | override fun onOptionsItemSelected(item: MenuItem): Boolean { 85 | return when (item.itemId) { 86 | R.id.filter_type -> { 87 | // TODO filter by type 88 | true 89 | } 90 | else -> super.onOptionsItemSelected(item) 91 | } 92 | } 93 | 94 | private fun addProductToCart(): (value: String) -> Unit { 95 | return { productId -> 96 | cartViewModel.addToCart(productId) 97 | } 98 | } 99 | 100 | private fun removeProductToCart(): (value: String) -> Unit { 101 | return { productId -> 102 | cartViewModel.removeFromCart(productId) 103 | } 104 | } 105 | 106 | private fun subscribeUiToCart(cartTotalTextView: TextView) { 107 | cartViewModel.uiState.observe(viewLifecycleOwner) { uiState -> 108 | cartTotalTextView.text = uiState.total 109 | } 110 | } 111 | 112 | private fun subscribeUiToProducts(adapter: DelegationAdapter) { 113 | viewModel.uiState.observe(viewLifecycleOwner) { uiState -> 114 | adapter.setData(uiState.products) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /.idea/codeStyles/Project.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 11 | 20 | 22 | 23 | 24 | 26 | 27 | 28 |
29 | 30 | 31 | 32 | xmlns:android 33 | 34 | ^$ 35 | 36 | 37 | 38 |
39 |
40 | 41 | 42 | 43 | xmlns:.* 44 | 45 | ^$ 46 | 47 | 48 | BY_NAME 49 | 50 |
51 |
52 | 53 | 54 | 55 | .*:id 56 | 57 | http://schemas.android.com/apk/res/android 58 | 59 | 60 | 61 |
62 |
63 | 64 | 65 | 66 | .*:name 67 | 68 | http://schemas.android.com/apk/res/android 69 | 70 | 71 | 72 |
73 |
74 | 75 | 76 | 77 | name 78 | 79 | ^$ 80 | 81 | 82 | 83 |
84 |
85 | 86 | 87 | 88 | style 89 | 90 | ^$ 91 | 92 | 93 | 94 |
95 |
96 | 97 | 98 | 99 | .* 100 | 101 | ^$ 102 | 103 | 104 | BY_NAME 105 | 106 |
107 |
108 | 109 | 110 | 111 | .* 112 | 113 | http://schemas.android.com/apk/res/android 114 | 115 | 116 | ANDROID_ATTRIBUTE_ORDER 117 | 118 |
119 |
120 | 121 | 122 | 123 | .* 124 | 125 | .* 126 | 127 | 128 | BY_NAME 129 | 130 |
131 |
132 |
133 |
134 | 135 | 137 |
138 |
-------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /app/src/main/res/drawable/ic_launcher_background.xml: -------------------------------------------------------------------------------- 1 | 2 | 7 | 10 | 15 | 20 | 25 | 30 | 35 | 40 | 45 | 50 | 55 | 60 | 65 | 70 | 75 | 80 | 85 | 90 | 95 | 100 | 105 | 110 | 115 | 120 | 125 | 130 | 135 | 140 | 145 | 150 | 155 | 160 | 165 | 170 | 171 | --------------------------------------------------------------------------------