diff --git a/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/1.json b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/1.json new file mode 100644 index 0000000..c23a7d1 --- /dev/null +++ b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/1.json @@ -0,0 +1,123 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "aa5f7d1cb5334f13a160fdbbaa00025a", + "entities": [ + { + "tableName": "product", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sku` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `image_uri` TEXT NOT NULL, PRIMARY KEY(`sku`))", + "fields": [ + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUri", + "columnName": "image_uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sku" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "inventory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `product_sku` TEXT NOT NULL, `price` REAL NOT NULL, `quantity_label` TEXT NOT NULL, `stock_available` INTEGER NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`product_sku`) REFERENCES `product`(`sku`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "quantityLabel", + "columnName": "quantity_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stockAvailable", + "columnName": "stock_available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_inventory_product_sku", + "unique": false, + "columnNames": [ + "product_sku" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inventory_product_sku` ON `${TABLE_NAME}` (`product_sku`)" + } + ], + "foreignKeys": [ + { + "table": "product", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "product_sku" + ], + "referencedColumns": [ + "sku" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'aa5f7d1cb5334f13a160fdbbaa00025a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/2.json b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/2.json new file mode 100644 index 0000000..5e89f53 --- /dev/null +++ b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/2.json @@ -0,0 +1,176 @@ +{ + "formatVersion": 1, + "database": { + "version": 2, + "identityHash": "7a03c797367df1fa0bb9ef49bd9aa88b", + "entities": [ + { + "tableName": "product", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sku` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `image_uri` TEXT NOT NULL, PRIMARY KEY(`sku`), FOREIGN KEY(`category`) REFERENCES `product_category`(`label`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUri", + "columnName": "image_uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sku" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_product_category", + "unique": false, + "columnNames": [ + "category" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_category` ON `${TABLE_NAME}` (`category`)" + } + ], + "foreignKeys": [ + { + "table": "product_category", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category" + ], + "referencedColumns": [ + "label" + ] + } + ] + }, + { + "tableName": "inventory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `product_sku` TEXT NOT NULL, `price` REAL NOT NULL, `quantity_label` TEXT NOT NULL, `stock_available` INTEGER NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`product_sku`) REFERENCES `product`(`sku`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "quantityLabel", + "columnName": "quantity_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stockAvailable", + "columnName": "stock_available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_inventory_product_sku", + "unique": false, + "columnNames": [ + "product_sku" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inventory_product_sku` ON `${TABLE_NAME}` (`product_sku`)" + } + ], + "foreignKeys": [ + { + "table": "product", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "product_sku" + ], + "referencedColumns": [ + "sku" + ] + } + ] + }, + { + "tableName": "product_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`label` TEXT NOT NULL, `name` TEXT NOT NULL, `sku_prefix` TEXT NOT NULL, PRIMARY KEY(`label`))", + "fields": [ + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skuPrefix", + "columnName": "sku_prefix", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "label" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7a03c797367df1fa0bb9ef49bd9aa88b')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/3.json b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/3.json new file mode 100644 index 0000000..ccbffbb --- /dev/null +++ b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/3.json @@ -0,0 +1,212 @@ +{ + "formatVersion": 1, + "database": { + "version": 3, + "identityHash": "35b9a3acca87c439cedd1d1c2477e19d", + "entities": [ + { + "tableName": "product", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sku` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `image_uri` TEXT NOT NULL, PRIMARY KEY(`sku`), FOREIGN KEY(`category`) REFERENCES `product_category`(`label`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUri", + "columnName": "image_uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sku" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_product_category", + "unique": false, + "columnNames": [ + "category" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_category` ON `${TABLE_NAME}` (`category`)" + } + ], + "foreignKeys": [ + { + "table": "product_category", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category" + ], + "referencedColumns": [ + "label" + ] + } + ] + }, + { + "tableName": "inventory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `product_sku` TEXT NOT NULL, `price` REAL NOT NULL, `quantity_label` TEXT NOT NULL, `stock_available` INTEGER NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`product_sku`) REFERENCES `product`(`sku`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "quantityLabel", + "columnName": "quantity_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stockAvailable", + "columnName": "stock_available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_inventory_product_sku", + "unique": false, + "columnNames": [ + "product_sku" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inventory_product_sku` ON `${TABLE_NAME}` (`product_sku`)" + } + ], + "foreignKeys": [ + { + "table": "product", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "product_sku" + ], + "referencedColumns": [ + "sku" + ] + } + ] + }, + { + "tableName": "product_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`label` TEXT NOT NULL, `name` TEXT NOT NULL, `sku_prefix` TEXT NOT NULL, PRIMARY KEY(`label`))", + "fields": [ + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skuPrefix", + "columnName": "sku_prefix", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "label" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `product_sku` TEXT NOT NULL, PRIMARY KEY(`type`, `product_sku`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "product_sku" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "weekly_favorite", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT product_sku FROM favorite WHERE type = 'WEEKLY'" + }, + { + "viewName": "monthly_favorite", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT product_sku FROM favorite WHERE type = 'MONTHLY'" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '35b9a3acca87c439cedd1d1c2477e19d')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/4.json b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/4.json new file mode 100644 index 0000000..b6c6fe1 --- /dev/null +++ b/app/schemas/com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase/4.json @@ -0,0 +1,242 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "540033191909308fd70590c7f0bc7654", + "entities": [ + { + "tableName": "product", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sku` TEXT NOT NULL, `name` TEXT NOT NULL, `category` TEXT NOT NULL, `image_uri` TEXT NOT NULL, PRIMARY KEY(`sku`), FOREIGN KEY(`category`) REFERENCES `product_category`(`label`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "sku", + "columnName": "sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUri", + "columnName": "image_uri", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "sku" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_product_category", + "unique": false, + "columnNames": [ + "category" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_product_category` ON `${TABLE_NAME}` (`category`)" + } + ], + "foreignKeys": [ + { + "table": "product_category", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "category" + ], + "referencedColumns": [ + "label" + ] + } + ] + }, + { + "tableName": "inventory", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `product_sku` TEXT NOT NULL, `price` REAL NOT NULL, `quantity_label` TEXT NOT NULL, `stock_available` INTEGER NOT NULL, `updated_at` TEXT NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`product_sku`) REFERENCES `product`(`sku`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "price", + "columnName": "price", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "quantityLabel", + "columnName": "quantity_label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "stockAvailable", + "columnName": "stock_available", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [ + { + "name": "index_inventory_product_sku", + "unique": false, + "columnNames": [ + "product_sku" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_inventory_product_sku` ON `${TABLE_NAME}` (`product_sku`)" + } + ], + "foreignKeys": [ + { + "table": "product", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "product_sku" + ], + "referencedColumns": [ + "sku" + ] + } + ] + }, + { + "tableName": "product_category", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`label` TEXT NOT NULL, `name` TEXT NOT NULL, `sku_prefix` TEXT NOT NULL, PRIMARY KEY(`label`))", + "fields": [ + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "skuPrefix", + "columnName": "sku_prefix", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "label" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "favorite", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` TEXT NOT NULL, `product_sku` TEXT NOT NULL, PRIMARY KEY(`type`, `product_sku`))", + "fields": [ + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "productSku", + "columnName": "product_sku", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "type", + "product_sku" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "cart_item", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`inventory_id` INTEGER NOT NULL, `quantity` INTEGER NOT NULL, PRIMARY KEY(`inventory_id`))", + "fields": [ + { + "fieldPath": "inventoryId", + "columnName": "inventory_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "quantity", + "columnName": "quantity", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "inventory_id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "weekly_favorite", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT product_sku FROM favorite WHERE type = 'WEEKLY'" + }, + { + "viewName": "monthly_favorite", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT product_sku FROM favorite WHERE type = 'MONTHLY'" + }, + { + "viewName": "cart_item_detail_view", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT inv.id AS inventory_id,inv.stock_available,inv.quantity_label,inv.price,p.name,p.image_uri,cart.quantity\n FROM cart_item AS cart\n INNER JOIN inventory as inv ON cart.inventory_id=inv.id\n INNER JOIN product AS p ON inv.product_sku=p.sku" + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '540033191909308fd70590c7f0bc7654')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/ItemSyncer.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/ItemSyncer.kt new file mode 100644 index 0000000..bb8e347 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/ItemSyncer.kt @@ -0,0 +1,99 @@ +/* + * Copyright 2018 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.kshitijpatil.tazabazar.data; + +import timber.log.Timber + +// Borrowed from https://github.com/chrisbanes/tivi/blob/main/data/src/main/java/app/tivi/data/syncers/ItemSyncer.kt + +/** + * @param NetworkType Network type + * @param LocalType local entity type + * @param Key Network ID type + */ +class ItemSyncer( + private val insertEntity: suspend (LocalType) -> Long, + private val updateEntity: suspend (LocalType) -> Unit, + private val deleteEntity: suspend (LocalType) -> Int, + private val localEntityToKey: suspend (LocalType) -> Key?, + private val networkEntityToKey: suspend (NetworkType) -> Key, + private val networkEntityToLocalEntity: suspend (NetworkType, Key?) -> LocalType, +) { + suspend fun sync( + currentValues: Collection, + networkValues: Collection, + removeNotMatched: Boolean = true + ): ItemSyncerResult { + val currentDbEntities = ArrayList(currentValues) + + val removed = ArrayList() + val added = ArrayList() + val updated = ArrayList() + + for (networkEntity in networkValues) { + Timber.v("Syncing item from network: %s", networkEntity) + + val remoteId = networkEntityToKey(networkEntity) ?: break + Timber.v("Mapped to remote ID: %s", remoteId) + + val dbEntityForId = currentDbEntities.find { + localEntityToKey(it) == remoteId + } + Timber.v("Matched database entity for remote ID %s : %s", remoteId, dbEntityForId) + + if (dbEntityForId != null) { + val localId = localEntityToKey(dbEntityForId) + val entity = networkEntityToLocalEntity(networkEntity, localId) + Timber.v("Mapped network entity to local entity: %s", entity) + if (dbEntityForId != entity) { + // This is currently in the DB, so lets merge it with the saved version + // and update it + updateEntity(entity) + Timber.v("Updated entry with remote id: %s", remoteId) + } + // Remove it from the list so that it is not deleted + currentDbEntities.remove(dbEntityForId) + updated += entity + } else { + // Not currently in the DB, so lets insert + added += networkEntityToLocalEntity(networkEntity, null) + } + } + + if (removeNotMatched) { + // Anything left in the set needs to be deleted from the database + currentDbEntities.forEach { + deleteEntity(it) + Timber.v("Deleted entry: %s", it) + removed += it + } + } + + // Finally we can insert all of the new entities + added.forEach { + insertEntity(it) + } + + return ItemSyncerResult(added, removed, updated) + } +} + +data class ItemSyncerResult( + val added: List = emptyList(), + val deleted: List = emptyList(), + val updated: List = emptyList() +) \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/ProductRepository.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/ProductRepository.kt index 5739c53..e1dc512 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/ProductRepository.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/ProductRepository.kt @@ -1,16 +1,16 @@ package com.kshitijpatil.tazabazar.data -import androidx.room.withTransaction -import com.kshitijpatil.tazabazar.data.local.AppDatabase -import com.kshitijpatil.tazabazar.data.local.entity.FavoriteEntity -import com.kshitijpatil.tazabazar.data.local.entity.FavoriteType +import com.kshitijpatil.tazabazar.data.local.TazaBazarDatabase +import com.kshitijpatil.tazabazar.data.local.TransactionRunner +import com.kshitijpatil.tazabazar.data.local.entity.* +import com.kshitijpatil.tazabazar.data.mapper.InventoryToInventoryEntity import com.kshitijpatil.tazabazar.data.mapper.ProductCategoryToProductCategoryEntity import com.kshitijpatil.tazabazar.data.mapper.ProductToProductWithInventories import com.kshitijpatil.tazabazar.data.mapper.ProductWithInventoriesToProduct +import com.kshitijpatil.tazabazar.model.Inventory import com.kshitijpatil.tazabazar.model.Product import com.kshitijpatil.tazabazar.model.ProductCategory import com.kshitijpatil.tazabazar.util.AppCoroutineDispatchers -import com.kshitijpatil.tazabazar.util.NetworkUtils import kotlinx.coroutines.withContext import timber.log.Timber @@ -38,24 +38,56 @@ interface ProductRepository { } class ProductRepositoryImpl( - private val productRemoteDataSource: ProductDataSource, + private val productRemoteSource: ProductDataSource, private val productLocalDataSource: ProductDataSource, - private val networkUtils: NetworkUtils, - private val appDatabase: AppDatabase, + private val appDatabase: TazaBazarDatabase, + private val transactionRunner: TransactionRunner, private val dispatchers: AppCoroutineDispatchers, private val productEntityMapper: ProductToProductWithInventories, private val productMapper: ProductWithInventoriesToProduct, + private val inventoryEntityMapper: InventoryToInventoryEntity, private val categoryEntityMapper: ProductCategoryToProductCategoryEntity ) : ProductRepository { + private val productDao = appDatabase.productDao + private val productCategoryDao = appDatabase.productCategoryDao + private val inventoryDao = appDatabase.inventoryDao + + private val productSyncer = ItemSyncer( + insertEntity = productDao::insert, + updateEntity = productDao::update, + deleteEntity = productDao::delete, + localEntityToKey = { it.sku }, + networkEntityToKey = { it.sku }, + networkEntityToLocalEntity = { entity, _ -> productEntityMapper.map(entity).product } + ) + + private val productCategorySyncer = ItemSyncer( + insertEntity = productCategoryDao::insert, + updateEntity = productCategoryDao::update, + deleteEntity = productCategoryDao::delete, + localEntityToKey = { it.label }, + networkEntityToKey = { it.label }, + networkEntityToLocalEntity = { entity, _ -> categoryEntityMapper.map(entity) } + ) + + private val inventorySyncer = ItemSyncer( + insertEntity = inventoryDao::insert, + updateEntity = inventoryDao::update, + deleteEntity = inventoryDao::delete, + localEntityToKey = { it.id }, + networkEntityToKey = { it.id }, + networkEntityToLocalEntity = { entity, _ -> inventoryEntityMapper.map(entity) } + ) + override suspend fun getProductCategories(forceRefresh: Boolean): List { - if (forceRefresh) refreshProductData() + if (forceRefresh) refreshProductCategories() return withContext(dispatchers.io) { productLocalDataSource.getProductCategories() } } override suspend fun getAllProducts(forceRefresh: Boolean): List { - if (forceRefresh) refreshProductData() + if (forceRefresh) refreshProducts() return withContext(dispatchers.io) { productLocalDataSource.getAllProducts() } @@ -94,52 +126,106 @@ class ProductRepositoryImpl( } } + private suspend fun runCatchingRemoteSource(getRemoteData: suspend ProductDataSource.() -> T): T? { + return withContext(dispatchers.io) { + runCatching { getRemoteData(productRemoteSource) } + }.getOrNull() + } + override suspend fun getProductListBy( category: String?, query: String?, forceRefresh: Boolean ): List { - if (forceRefresh) refreshProductData() + if (forceRefresh) { + val remoteProducts = productRemoteSource.getProductsBy(category, query) + selectiveSyncProductAndInventories(remoteProducts) + return mapFavoritesFor(remoteProducts) + } return withContext(dispatchers.io) { Timber.d("Retrieving products for category: $category , query: $query") productLocalDataSource.getProductsBy(category, query) } } - override suspend fun refreshProductData() { - withContext(dispatchers.io) { - if (!networkUtils.hasNetworkConnection()) { - Timber.d("Failed to refresh! No internet connection") - return@withContext + private suspend fun mapFavoritesFor(remoteProducts: List): List { + return withContext(dispatchers.computation) { + val allFavorites = appDatabase.favoriteDao.getAllFavorites() + val favoritesMap = allFavorites + .groupBy { it.productSku } + remoteProducts.map { product -> + val productFavorites = favoritesMap[product.sku]?.map { it.type }?.toSet() + if (productFavorites != null) { + product.copy(favorites = productFavorites) + } else product } - Timber.d("Synchronising Product Categories") - val remoteData = productRemoteDataSource.getProductCategories() - .map(categoryEntityMapper::map) - Timber.d("Received ${remoteData.size} categories from the remote source") + } + } - Timber.d("Synchronising Product and Inventories") - val remoteProducts = productRemoteDataSource.getAllProducts() - .map(productEntityMapper::map) - Timber.d("Received ${remoteProducts.size} products from the remote source") - val allInventories = remoteProducts + private suspend fun selectiveSyncProductAndInventories(remoteProducts: List) { + withContext(dispatchers.io) { + val mappedProductWithInventories = remoteProducts.map(productEntityMapper::map) + val allInventories = mappedProductWithInventories .map { it.inventories } .flatten() .toList() - appDatabase.withTransaction { - // NOTE: Cascading - appDatabase.productCategoryDao.deleteAll() // To avoid any inconsistencies - appDatabase.productCategoryDao.insertAll(remoteData) - // NO for insert in for-loop - appDatabase.productDao.insertAll(remoteProducts.map { it.product }) + + // REPLACE strategy will make sure to delete the + // the inventories of changed products due to CASCADE + // behaviour on the InventoryEntity + transactionRunner { + appDatabase.productDao.insertAll(mappedProductWithInventories.map { it.product }) appDatabase.inventoryDao.insertAll(allInventories) } } } + private suspend fun refreshProductCategories() { + withContext(dispatchers.io) { + Timber.d("Synchronising Product Categories") + //val remoteCategories = runCatchingRemoteSource { getProductCategories() } + val remoteCategories = productRemoteSource.getProductCategories() + Timber.d("Received ${remoteCategories.size} categories from the remote source") + val localCategories = appDatabase.productCategoryDao.getAllCategories() + productCategorySyncer.sync( + localCategories, + remoteCategories, + removeNotMatched = true + ) + } + } + + private suspend fun refreshProducts() { + withContext(dispatchers.io) { + Timber.d("Synchronising Products") + //val remoteProducts = runCatchingRemoteSource { getAllProducts() } + val remoteProducts = productRemoteSource.getAllProducts() + Timber.d("Received ${remoteProducts.size} products from the remote source") + val localProducts = productDao.getAllProducts() + productSyncer.sync(localProducts, remoteProducts, removeNotMatched = true) + val remoteInventories = remoteProducts.map { it.inventories }.flatten() + refreshInventories(remoteInventories) + } + } + + private suspend fun refreshInventories(remoteInventories: List) { + withContext(dispatchers.io) { + Timber.d("Synchronising Product Inventories") + val localInventories = appDatabase.inventoryDao.getAllInventories() + Timber.d("Received ${remoteInventories.size} inventories from the remote source") + inventorySyncer.sync(localInventories, remoteInventories, removeNotMatched = true) + } + } + + override suspend fun refreshProductData() { + refreshProductCategories() + refreshProducts() + } + override suspend fun updateFavorites(productSku: String, favoriteChoices: Set) { Timber.d("Updating favorites for productSku=$productSku to $favoriteChoices") withContext(dispatchers.io) { - appDatabase.withTransaction { + transactionRunner { appDatabase.favoriteDao.deleteFavoritesBySku(productSku) val favoriteEntities = favoriteChoices.map { FavoriteEntity(it, productSku) } appDatabase.favoriteDao.insertAll(favoriteEntities) diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/RoomTransactionRunner.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/RoomTransactionRunner.kt new file mode 100644 index 0000000..ba74ed5 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/RoomTransactionRunner.kt @@ -0,0 +1,9 @@ +package com.kshitijpatil.tazabazar.data.local + +import androidx.room.withTransaction + +class RoomTransactionRunner(private val db: TazaBazarRoomDatabase) : TransactionRunner { + override suspend fun invoke(block: suspend () -> R): R { + return db.withTransaction(block) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarDatabase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarDatabase.kt new file mode 100644 index 0000000..3c3a381 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarDatabase.kt @@ -0,0 +1,11 @@ +package com.kshitijpatil.tazabazar.data.local + +import com.kshitijpatil.tazabazar.data.local.dao.* + +interface TazaBazarDatabase { + val productDao: ProductDao + val inventoryDao: InventoryDao + val productCategoryDao: ProductCategoryDao + val favoriteDao: FavoriteDao + val cartItemDao: CartItemDao +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/AppDatabase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarRoomDatabase.kt similarity index 71% rename from app/src/main/java/com/kshitijpatil/tazabazar/data/local/AppDatabase.kt rename to app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarRoomDatabase.kt index dbd12b9..1dd6728 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/AppDatabase.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TazaBazarRoomDatabase.kt @@ -4,7 +4,6 @@ import androidx.room.AutoMigration import androidx.room.Database import androidx.room.RoomDatabase import androidx.room.TypeConverters -import com.kshitijpatil.tazabazar.data.local.dao.* import com.kshitijpatil.tazabazar.data.local.entity.* @Database( @@ -27,13 +26,7 @@ import com.kshitijpatil.tazabazar.data.local.entity.* ] ) @TypeConverters(TazaBazarTypeConverters::class) -abstract class AppDatabase : RoomDatabase() { - - abstract val productDao: ProductDao - abstract val inventoryDao: InventoryDao - abstract val productCategoryDao: ProductCategoryDao - abstract val favoriteDao: FavoriteDao - abstract val cartItemDao: CartItemDao +abstract class TazaBazarRoomDatabase : RoomDatabase(), TazaBazarDatabase { companion object { const val databaseName = "tazabazaar-db" diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TransactionRunner.kt b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TransactionRunner.kt new file mode 100644 index 0000000..948a481 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/data/local/TransactionRunner.kt @@ -0,0 +1,6 @@ +package com.kshitijpatil.tazabazar.data.local + +interface TransactionRunner { + suspend operator fun invoke(block: suspend () -> R): R +} + diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt index ba93c7e..70d4b10 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/DomainModule.kt @@ -85,4 +85,13 @@ object DomainModule { val repo = RepositoryModule.provideOrderRepository(context, applicationScope, dispatchers) return GetUserOrdersUseCase(dispatchers.io, repo) } + + + fun provideSearchProductsUseCase( + ioDispatcher: CoroutineDispatcher, + context: Context + ): SearchProductsUseCase { + val repo = RepositoryModule.provideProductRepository(context) + return SearchProductsUseCase(ioDispatcher, repo) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt index f0684fc..83b5085 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/RepositoryModule.kt @@ -7,8 +7,7 @@ import com.kshitijpatil.tazabazar.api.ApiModule import com.kshitijpatil.tazabazar.api.ProductApi import com.kshitijpatil.tazabazar.api.dto.ApiError import com.kshitijpatil.tazabazar.data.* -import com.kshitijpatil.tazabazar.data.local.AppDatabase -import com.kshitijpatil.tazabazar.data.local.ProductLocalDataSource +import com.kshitijpatil.tazabazar.data.local.* import com.kshitijpatil.tazabazar.data.local.prefs.AuthPreferenceStore import com.kshitijpatil.tazabazar.data.local.prefs.AuthPreferenceStoreImpl import com.kshitijpatil.tazabazar.data.mapper.EitherStringSerializer @@ -30,7 +29,7 @@ object RepositoryModule { private val appDispatchers = AppModule.provideAppCoroutineDispatchers() private val moshi = Moshi.Builder().build() private val lock = Any() - private var database: AppDatabase? = null + private var database: TazaBazarRoomDatabase? = null private val apiErrorMapper by lazy { ErrorBodyDecoder(moshi.adapter(ApiError::class.java)) } @@ -69,18 +68,19 @@ object RepositoryModule { } private fun createProductRepository(context: Context): ProductRepository { - val appDatabase = database ?: createDatabase(context) + val tazaBazarDatabase = provideTazaBazarDatabase(context) val client = OkhttpModule.provideOkHttpClient(context) val api = ApiModule.provideProductApi(client) - val networkUtils = provideNetworkUtils(context) + val transactionRunner = provideRoomTransactionRunner(context) val newRepo = ProductRepositoryImpl( provideRemoteDataSource(api), - provideLocalDataSource(appDatabase), - networkUtils, - appDatabase, + provideLocalDataSource(tazaBazarDatabase), + tazaBazarDatabase, + transactionRunner, appDispatchers, MapperModule.productToProductWithInventories, MapperModule.productWithInventoriesToProduct, + MapperModule.inventoryToInventoryEntity, MapperModule.productCategoryToProductCategoryEntity ) productRepository = newRepo @@ -94,19 +94,19 @@ object RepositoryModule { } private fun createCartRepository(context: Context): CartRepository { - val appDatabase = database ?: createDatabase(context) + val tazaBazarDatabase = provideTazaBazarDatabase(context) val mapper = MapperModule.cartItemDetailViewToCartItem val dispatchers = AppModule.provideAppCoroutineDispatchers() - val repo = CartRepositoryImpl(appDatabase.cartItemDao, dispatchers, mapper) + val repo = CartRepositoryImpl(tazaBazarDatabase.cartItemDao, dispatchers, mapper) cartRepository = repo return repo } - fun provideLocalDataSource(appDatabase: AppDatabase): ProductDataSource { + fun provideLocalDataSource(tazaBazarDatabase: TazaBazarDatabase): ProductDataSource { return ProductLocalDataSource( - favoriteDao = appDatabase.favoriteDao, + favoriteDao = tazaBazarDatabase.favoriteDao, productMapper = MapperModule.productWithInventoriesAndFavoritesToProduct, - productCategoryDao = appDatabase.productCategoryDao + productCategoryDao = tazaBazarDatabase.productCategoryDao ) } @@ -118,9 +118,13 @@ object RepositoryModule { ) } - private fun createDatabase(context: Context): AppDatabase { + private fun createRoomDatabase(context: Context): TazaBazarRoomDatabase { val result = - Room.databaseBuilder(context, AppDatabase::class.java, AppDatabase.databaseName) + Room.databaseBuilder( + context, + TazaBazarRoomDatabase::class.java, + TazaBazarRoomDatabase.databaseName + ) .fallbackToDestructiveMigration() .build() database = result @@ -204,7 +208,7 @@ object RepositoryModule { val client = OkhttpModule.provideOkHttpClient(context) val orderApiFactory = provideOrderApiFactory(client) val authPreferenceStore = provideAuthPreferenceStore(context) - val database = provideAppDatabase(context) + val database = provideTazaBazarDatabase(context) val repo = OrderRepositoryImpl( externalScope, dispatchers, @@ -217,8 +221,12 @@ object RepositoryModule { return repo } - fun provideAppDatabase(context: Context): AppDatabase { - return database ?: createDatabase(context) + fun provideTazaBazarDatabase(context: Context): TazaBazarDatabase { + return database ?: createRoomDatabase(context) + } + + fun provideTazaBazarRoomDatabase(context: Context): TazaBazarRoomDatabase { + return database ?: createRoomDatabase(context) } fun provideOrderApiFactory(client: OkHttpClient): OrderApiFactory { @@ -245,4 +253,9 @@ object RepositoryModule { fun provideLoggedInUserSerializer(): EitherStringSerializer { return LoggedInUserSerializer(loggedInUserJsonAdapter) } + + fun provideRoomTransactionRunner(context: Context): TransactionRunner { + val db = provideTazaBazarRoomDatabase(context) + return RoomTransactionRunner(db) + } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt b/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt index df3c88f..ee245a2 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/di/ViewModelFactory.kt @@ -27,6 +27,8 @@ class HomeViewModelFactory( RepositoryModule.provideProductRepository(appContext) private val ioDispatcher = AppModule.provideIoDispatcher() private val addToCartUseCase = DomainModule.provideAddToCartUseCase(appContext, ioDispatcher) + private val searchProductsUseCase = + DomainModule.provideSearchProductsUseCase(ioDispatcher, appContext) @Suppress("UNCHECKED_CAST") override fun create( @@ -35,7 +37,12 @@ class HomeViewModelFactory( handle: SavedStateHandle ): T { if (modelClass.isAssignableFrom(HomeViewModel::class.java)) { - return HomeViewModel(handle, productRepository, addToCartUseCase) as T + return HomeViewModel( + savedStateHandle = handle, + productRepository = productRepository, + searchProductsUseCase = searchProductsUseCase, + addOrUpdateCartItemUseCase = addToCartUseCase + ) as T } throw IllegalArgumentException() } diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/domain/FlowUseCase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/domain/FlowUseCase.kt index f91b44a..4904979 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/domain/FlowUseCase.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/domain/FlowUseCase.kt @@ -3,11 +3,14 @@ package com.kshitijpatil.tazabazar.domain import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.* -abstract class FlowUseCase(private val dispatcher: CoroutineDispatcher? = null) { +abstract class FlowUseCase( + private val dispatcher: CoroutineDispatcher? = null, + conflateParams: Boolean = true +) { private val paramState = MutableSharedFlow

() private val flow: Flow = paramState - .distinctUntilChanged() + .apply { if (conflateParams) distinctUntilChanged() } .flatMapLatest { createObservable(it) } .distinctUntilChanged() .apply { dispatcher?.let { flowOn(it) } } diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/domain/MediatorResult.kt b/app/src/main/java/com/kshitijpatil/tazabazar/domain/MediatorResult.kt new file mode 100644 index 0000000..d618440 --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/domain/MediatorResult.kt @@ -0,0 +1,34 @@ +package com.kshitijpatil.tazabazar.domain + +enum class ResponseOrigin { + LOCAL, REMOTE +} + +sealed class MediatorResult { + abstract val responseOrigin: ResponseOrigin + + data class Success( + val data: T, + override val responseOrigin: ResponseOrigin + ) : MediatorResult() + + data class Error( + val exception: Throwable, + override val responseOrigin: ResponseOrigin + ) : MediatorResult() + + data class Loading(override val responseOrigin: ResponseOrigin) : MediatorResult() + + override fun toString(): String { + return when (this) { + is Success<*> -> "Success[data=$data, origin=$responseOrigin]" + is Error -> "Error[exception=$exception, origin=$responseOrigin]" + is Loading -> "Loading[origin=$responseOrigin]" + } + } + + fun isRemoteOrigin(): Boolean = responseOrigin == ResponseOrigin.REMOTE +} + +val MediatorResult.data: T? + get() = (this as? MediatorResult.Success)?.data \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/domain/SearchProductsUseCase.kt b/app/src/main/java/com/kshitijpatil/tazabazar/domain/SearchProductsUseCase.kt new file mode 100644 index 0000000..f0868ce --- /dev/null +++ b/app/src/main/java/com/kshitijpatil/tazabazar/domain/SearchProductsUseCase.kt @@ -0,0 +1,46 @@ +package com.kshitijpatil.tazabazar.domain + +import com.kshitijpatil.tazabazar.data.ProductRepository +import com.kshitijpatil.tazabazar.model.Product +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +class SearchProductsUseCase( + ioDispatcher: CoroutineDispatcher, + private val productRepository: ProductRepository +) : FlowUseCase>>( + ioDispatcher, + conflateParams = false +) { + data class Params( + val query: String? = null, + val category: String? = null, + val forceRefresh: Boolean = false + ) + + override fun createObservable(params: Params): Flow>> { + return flow { + emit(MediatorResult.Loading(ResponseOrigin.LOCAL)) + val localProducts = productRepository.getProductListBy(params.category, params.query) + emit(MediatorResult.Success(localProducts, ResponseOrigin.LOCAL)) + // refresh if asked for or couldn't find any results + // in the local source + val refresh = params.forceRefresh || localProducts.isEmpty() + if (refresh) { + emit(MediatorResult.Loading(ResponseOrigin.REMOTE)) + runCatching { + productRepository.getProductListBy( + category = params.category, + query = params.query, + forceRefresh = refresh + ) + }.onSuccess { + emit(MediatorResult.Success(it, ResponseOrigin.REMOTE)) + }.onFailure { + emit(MediatorResult.Error(it, ResponseOrigin.REMOTE)) + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/MainActivity.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/MainActivity.kt index e245db4..ab747c7 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/MainActivity.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/MainActivity.kt @@ -7,7 +7,6 @@ import android.widget.EditText import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import com.kshitijpatil.tazabazar.R -import timber.log.Timber class MainActivity : AppCompatActivity(R.layout.activity_main) { private val viewModel: MainActivityViewModel by viewModels() @@ -20,7 +19,6 @@ class MainActivity : AppCompatActivity(R.layout.activity_main) { } private fun clearFocusOnOutSideClick() { - Timber.d("Clearing focus") currentFocus?.apply { if (this is EditText) clearFocus() diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/HomeViewModel.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/HomeViewModel.kt index cafa19e..10f1540 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/HomeViewModel.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/HomeViewModel.kt @@ -6,19 +6,20 @@ import androidx.lifecycle.viewModelScope import com.kshitijpatil.tazabazar.data.ProductRepository import com.kshitijpatil.tazabazar.data.local.entity.FavoriteType import com.kshitijpatil.tazabazar.domain.AddOrUpdateCartItemUseCase +import com.kshitijpatil.tazabazar.domain.MediatorResult import com.kshitijpatil.tazabazar.domain.Result +import com.kshitijpatil.tazabazar.domain.SearchProductsUseCase import com.kshitijpatil.tazabazar.model.Inventory import com.kshitijpatil.tazabazar.model.Product import com.kshitijpatil.tazabazar.model.ProductCategory -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.* import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.launch -import timber.log.Timber class HomeViewModel( private val savedStateHandle: SavedStateHandle, private val productRepository: ProductRepository, + private val searchProductsUseCase: SearchProductsUseCase, private val addOrUpdateCartItemUseCase: AddOrUpdateCartItemUseCase ) : ViewModel() { companion object { @@ -55,10 +56,20 @@ class HomeViewModel( private val _uiEvents = MutableSharedFlow() val uiEvents: SharedFlow - get() = _uiEvents.asSharedFlow() + get() = _uiEvents.shareIn(viewModelScope, WhileSubscribed(5000)) init { - var refreshJob: Job? = null + viewModelScope.launch { observeProductSearchResults() } + viewModelScope.launch { loadProductCategories(forceRefresh = true) } + viewModelScope.launch { + loadProducts( + query = null, + category = null, + forceRefresh = true + ) + } + viewModelScope.launch { observeProductFilters() } + /*var refreshJob: Job? = null if (cacheExpired) { refreshJob = viewModelScope.launch { productRepository.refreshProductData() } } @@ -73,6 +84,59 @@ class HomeViewModel( Timber.d("Filter updated: $it") updateProductList(it) } + }*/ + } + + private suspend fun observeProductFilters() { + _filter.collect { + val params = SearchProductsUseCase.Params(it.query, it.category) + searchProductsUseCase(params) + } + } + + private suspend fun loadProducts( + query: String?, + category: String?, + forceRefresh: Boolean = false + ) { + // SearchProductsUseCase will take care of emitting + // local items first followed by remote items if succeed + val params = SearchProductsUseCase.Params(query, category, forceRefresh) + searchProductsUseCase(params) + } + + private suspend fun loadProductWithCurrentFilters(forceRefresh: Boolean = false) { + val currentFilters = _filter.value + loadProducts(currentFilters.query, currentFilters.category, forceRefresh) + } + + private suspend fun loadProductCategories(forceRefresh: Boolean) { + // get local first + _productCategories.emit(productRepository.getProductCategories()) + // try remote + runCatching { + productRepository.getProductCategories(forceRefresh = forceRefresh) + }.onSuccess { _productCategories.emit(it) } + } + + private suspend fun observeProductSearchResults() { + searchProductsUseCase.observe().collect { + when (it) { + is MediatorResult.Error -> { + if (it.isRemoteOrigin()) { + _uiEvents.emit(UiEvent.FetchCompleted.Failure) + } + } + is MediatorResult.Loading -> { + if (it.isRemoteOrigin()) + _uiEvents.emit(UiEvent.FetchingProducts) + } + is MediatorResult.Success -> { + if (it.isRemoteOrigin()) + _uiEvents.emit(UiEvent.FetchCompleted.Success) + _productList.emit(it.data) + } + } } } @@ -82,11 +146,13 @@ class HomeViewModel( * @param forceRefresh whether to fetch data from the remote source */ suspend fun reloadProductsData(forceRefresh: Boolean = false) { - if (forceRefresh) { + /*if (forceRefresh) { productRepository.refreshProductData() - } - _productCategories.emit(productRepository.getProductCategories()) - updateProductList(_filter.value) + }*/ + //_productCategories.emit(productRepository.getProductCategories()) + loadProductCategories(forceRefresh) + loadProductWithCurrentFilters(forceRefresh) + //updateProductList(_filter.value) } private fun updateProductList(filterParams: FilterParams) { @@ -123,7 +189,8 @@ class HomeViewModel( fun updateFavorites(productSku: String, favoriteChoices: Set) { viewModelScope.launch { productRepository.updateFavorites(productSku, favoriteChoices) - updateProductList(_filter.value) + //updateProductList(_filter.value) + loadProductWithCurrentFilters() } } @@ -144,5 +211,10 @@ class HomeViewModel( sealed class UiEvent { object ClearFilters : UiEvent() + object FetchingProducts : UiEvent() + sealed class FetchCompleted : UiEvent() { + object Failure : FetchCompleted() + object Success : FetchCompleted() + } } } \ No newline at end of file diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductFilterFragment.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductFilterFragment.kt index 5c16277..80269ec 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductFilterFragment.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductFilterFragment.kt @@ -16,7 +16,6 @@ import com.kshitijpatil.tazabazar.util.launchAndRepeatWithViewLifecycle import com.kshitijpatil.tazabazar.util.textChanges import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch import timber.log.Timber @@ -47,17 +46,26 @@ class ProductFilterFragment : Fragment() { launchAndRepeatWithViewLifecycle { launch { observeSearchQuery() } launch { observeCategoryFilters() } - launch { observeClearFiltersEvent() } + launch { observeUiEvents() } } } - private suspend fun observeClearFiltersEvent() { + private suspend fun observeUiEvents() { viewModel.uiEvents - .filter { it is HomeViewModel.UiEvent.ClearFilters } .collect { - Timber.d("product-filters: ClearFilters Event received") - binding.cgProductCategories.clearCheck() - binding.textFieldSearch.editText?.setText("") + when (it) { + HomeViewModel.UiEvent.ClearFilters -> { + Timber.d("product-filters: ClearFilters Event received") + binding.cgProductCategories.clearCheck() + binding.textFieldSearch.editText?.setText("") + } + is HomeViewModel.UiEvent.FetchCompleted -> { + binding.progressCategories.isVisible = false + } + HomeViewModel.UiEvent.FetchingProducts -> { + binding.progressCategories.isVisible = true + } + } } } diff --git a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductListAdapter.kt b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductListAdapter.kt index 41140a9..7e73786 100644 --- a/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductListAdapter.kt +++ b/app/src/main/java/com/kshitijpatil/tazabazar/ui/home/ProductListAdapter.kt @@ -23,11 +23,6 @@ class ProductListAdapter( return ProductViewHolder(view, loadImageDelegate, onItemActionCallback) } - override fun onViewDetachedFromWindow(holder: ProductViewHolder) { - holder.onItemActionCallback = null - super.onViewDetachedFromWindow(holder) - } - override fun onBindViewHolder(holder: ProductViewHolder, position: Int) { val item = getItem(position) holder.bind(item) diff --git a/app/src/test/java/com/kshitijpatil/tazabazar/data/ProductRepositoryImplTest.kt b/app/src/test/java/com/kshitijpatil/tazabazar/data/ProductRepositoryImplTest.kt index cf1a69c..e992ca0 100644 --- a/app/src/test/java/com/kshitijpatil/tazabazar/data/ProductRepositoryImplTest.kt +++ b/app/src/test/java/com/kshitijpatil/tazabazar/data/ProductRepositoryImplTest.kt @@ -5,7 +5,7 @@ import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.common.truth.Truth.assertThat -import com.kshitijpatil.tazabazar.data.local.AppDatabase +import com.kshitijpatil.tazabazar.data.local.TazaBazarRoomDatabase import com.kshitijpatil.tazabazar.data.local.TestInject import com.kshitijpatil.tazabazar.di.MapperModule import com.kshitijpatil.tazabazar.di.RepositoryModule @@ -15,17 +15,16 @@ import com.kshitijpatil.tazabazar.model.ProductCategory import com.kshitijpatil.tazabazar.test.util.MainCoroutineRule import com.kshitijpatil.tazabazar.test.util.runBlockingTest import com.kshitijpatil.tazabazar.util.AppCoroutineDispatchers -import com.kshitijpatil.tazabazar.util.NetworkUtils import kotlinx.coroutines.runBlocking import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mockito.kotlin.mock +import java.io.IOException import java.util.concurrent.Executors /** - * We can't use 'Fake' Database since the repository is using - * transactional operations of the database + * TODO: Use Fake Database to run these tests without instrumentation */ @RunWith(AndroidJUnit4::class) class ProductRepositoryImplTest { @@ -37,6 +36,7 @@ class ProductRepositoryImplTest { @get:Rule var coroutineRule = MainCoroutineRule() + private val context = ApplicationProvider.getApplicationContext() private lateinit var repo: ProductRepository private val testDispatcher = coroutineRule.testDispatcher private val testAppDispatchers = @@ -53,7 +53,7 @@ class ProductRepositoryImplTest { appDatabase.productDao.insertAll(dbProducts) } val localDataSource = RepositoryModule.provideLocalDataSource(appDatabase) - repo = provideProductRepoImpl(mock(), localDataSource, appDatabase, ConnectedNetworkUtils) + repo = provideProductRepoImpl(mock(), localDataSource, appDatabase) coroutineRule.runBlockingTest { // When asked for all products @@ -85,8 +85,7 @@ class ProductRepositoryImplTest { repo = provideProductRepoImpl( remoteSource, localDataSource, - appDatabase, - ConnectedNetworkUtils + appDatabase ) runBlocking { @@ -108,7 +107,7 @@ class ProductRepositoryImplTest { val categoryMapper = MapperModule.productCategoryEntityToProductCategory runBlocking { appDatabase.productCategoryDao.insertAll(allCategoryEntities) } val localDataSource = RepositoryModule.provideLocalDataSource(appDatabase) - repo = provideProductRepoImpl(mock(), localDataSource, appDatabase, ConnectedNetworkUtils) + repo = provideProductRepoImpl(mock(), localDataSource, appDatabase) coroutineRule.runBlockingTest { val actual = repo.getProductCategories() @@ -128,8 +127,7 @@ class ProductRepositoryImplTest { repo = provideProductRepoImpl( remoteSource, localDataSource, - appDatabase, - ConnectedNetworkUtils + appDatabase ) runBlocking { @@ -156,7 +154,7 @@ class ProductRepositoryImplTest { ) val localSource = RepositoryModule.provideLocalDataSource(appDatabase) val repo = - provideProductRepoImpl(remoteSource, localSource, appDatabase, ConnectedNetworkUtils) + provideProductRepoImpl(remoteSource, localSource, appDatabase) runBlocking { // when called refresh data @@ -169,44 +167,17 @@ class ProductRepositoryImplTest { } } - @Test - fun whenForceRefresh_andNotConnected_shouldFallbackToLocalSource() { - // given local and remote sources with distinct data + @Test(expected = IOException::class) + fun whenForceRefresh_andNotConnected_shouldThrowIOException() { val appDatabase = provideAppDatabase() - val productMapper = MapperModule.productWithInventoriesToProduct - val categoryMapper = MapperModule.productCategoryEntityToProductCategory - val remoteCategories = listOf(vegetables) - val remoteProducts = listOf(tomatoRedProductWithInventories) - val remoteSource = FakeRemoteDataSource( - remoteProducts.map(productMapper::map), - remoteCategories.map(categoryMapper::map) - ) - val localCategories = listOf(fruits) - val localProducts = listOf(sitafalProductWithInventories) - runBlocking { - appDatabase.productCategoryDao.insertAll(localCategories) - localProducts.forEach { - appDatabase.productDao.insertProductAndInventories(it.product, it.inventories) - } - } + val remoteSource = FakeRemoteDataSource(null, null) val localSource = RepositoryModule.provideLocalDataSource(appDatabase) - // and network not connected - val repo = - provideProductRepoImpl(remoteSource, localSource, appDatabase, DisconnectedNetworkUtils) - coroutineRule.runBlockingTest { - // when forced to refresh - val actualProducts = repo.getAllProducts(forceRefresh = true) - // should fallback to local store - assertThat(actualProducts).containsExactlyElementsIn(localProducts.map(productMapper::map)) - // when forced to refresh - val actualCategories = repo.getProductCategories(forceRefresh = true) - // should fallback to local store - assertThat(actualCategories).containsExactlyElementsIn( - localCategories.map( - categoryMapper::map - ) - ) - } + val repo = provideProductRepoImpl( + remoteSource, + localSource, + appDatabase + ) + runBlocking { repo.getAllProducts(forceRefresh = true) } } /** @@ -216,24 +187,23 @@ class ProductRepositoryImplTest { private fun provideProductRepoImpl( remoteSource: ProductDataSource, localSource: ProductDataSource, - appDatabase: AppDatabase, - networkUtils: NetworkUtils + appDatabase: TazaBazarRoomDatabase ): ProductRepositoryImpl { return ProductRepositoryImpl( remoteSource, localSource, - networkUtils, appDatabase, + RepositoryModule.provideRoomTransactionRunner(context), testAppDispatchers, MapperModule.productToProductWithInventories, MapperModule.productWithInventoriesToProduct, + MapperModule.inventoryToInventoryEntity, MapperModule.productCategoryToProductCategoryEntity ) } private val transactionExecutor = Executors.newSingleThreadExecutor() - private fun provideAppDatabase(withTransactionExecutor: Boolean = false): AppDatabase { - val context = ApplicationProvider.getApplicationContext() + private fun provideAppDatabase(withTransactionExecutor: Boolean = false): TazaBazarRoomDatabase { return if (withTransactionExecutor) TestInject.appDatabase(context, transactionExecutor) else @@ -246,20 +216,18 @@ class ProductRepositoryImplTest { * when asked for all or filtered lists */ class FakeRemoteDataSource( - private val products: List, - private val productCategories: List + private val products: List? = null, + private val productCategories: List? = null ) : ProductDataSource { - override suspend fun getProductCategories() = productCategories - - override suspend fun getAllProducts() = products - - override suspend fun getProductsBy(category: String?, query: String?) = products -} + override suspend fun getProductCategories(): List { + return productCategories ?: throw IOException() + } -val ConnectedNetworkUtils = object : NetworkUtils(mock()) { - override fun hasNetworkConnection() = true -} + override suspend fun getAllProducts(): List { + return products ?: throw IOException() + } -val DisconnectedNetworkUtils = object : NetworkUtils(mock()) { - override fun hasNetworkConnection() = false + override suspend fun getProductsBy(category: String?, query: String?): List { + return products ?: throw IOException() + } } \ No newline at end of file diff --git a/app/src/test/java/com/kshitijpatil/tazabazar/data/local/TestInject.kt b/app/src/test/java/com/kshitijpatil/tazabazar/data/local/TestInject.kt index f73d3c2..c128b7a 100644 --- a/app/src/test/java/com/kshitijpatil/tazabazar/data/local/TestInject.kt +++ b/app/src/test/java/com/kshitijpatil/tazabazar/data/local/TestInject.kt @@ -7,17 +7,17 @@ import java.util.concurrent.Executor object TestInject { // In case transaction tests start causing issue with runBlockingTest // setTransactionExecutor(Executors.newSingleThreadExecutor()) - fun appDatabase(context: Context): AppDatabase { + fun appDatabase(context: Context): TazaBazarRoomDatabase { return Room.inMemoryDatabaseBuilder( context, - AppDatabase::class.java + TazaBazarRoomDatabase::class.java ).allowMainThreadQueries().build() } - fun appDatabase(context: Context, transactionExecutor: Executor): AppDatabase { + fun appDatabase(context: Context, transactionExecutor: Executor): TazaBazarRoomDatabase { return Room.inMemoryDatabaseBuilder( context, - AppDatabase::class.java + TazaBazarRoomDatabase::class.java ) .allowMainThreadQueries() .setTransactionExecutor(transactionExecutor)