diff --git a/cart-utils/.gitignore b/cart-utils/.gitignore new file mode 100644 index 0000000..796b96d --- /dev/null +++ b/cart-utils/.gitignore @@ -0,0 +1 @@ +/build diff --git a/cart-utils/build.gradle b/cart-utils/build.gradle new file mode 100644 index 0000000..ff9abdc --- /dev/null +++ b/cart-utils/build.gradle @@ -0,0 +1,22 @@ +apply from: "../android-configs/lib-config.gradle" + +dependencies { + def coroutinesVersion = '1.6.4' + def junitVersion = '4.13.2' + + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") + testImplementation("junit:junit") + + constraints { + implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core") { + version { + require(coroutinesVersion) + } + } + testImplementation("junit:junit") { + version { + require(junitVersion) + } + } + } +} diff --git a/cart-utils/src/main/AndroidManifest.xml b/cart-utils/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8a05ba2 --- /dev/null +++ b/cart-utils/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt new file mode 100644 index 0000000..baa8ed5 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/CartModel.kt @@ -0,0 +1,35 @@ +package ru.touchin.roboswag.cart_utils.models + +abstract class CartModel { + + abstract val products: List + + open val promocodeList: List = emptyList() + + open val availableBonuses: Int = 0 + open val usedBonuses: Int = 0 + + val availableProducts: List + get() = products.filter { it.isAvailable && !it.isDeleted } + + val totalPrice: Int + get() = availableProducts.sumOf { it.countInCart * it.price } + + val totalBonuses: Int + get() = availableProducts.sumOf { it.countInCart * (it.bonuses ?: 0) } + + fun getPriceWithPromocode(): Int = promocodeList + .sortedByDescending { it.discount is PromocodeDiscount.ByPercent } + .fold(initial = totalPrice) { price, promo -> + promo.discount.applyTo(price) + } + + abstract fun copyWith( + products: List = this.products, + promocodeList: List = this.promocodeList, + usedBonuses: Int = this.usedBonuses + ): TCart + + @Suppress("UNCHECKED_CAST") + fun asCart() = this as TCart +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt new file mode 100644 index 0000000..56c2758 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/ProductModel.kt @@ -0,0 +1,25 @@ +package ru.touchin.roboswag.cart_utils.models + +abstract class ProductModel { + abstract val id: Int + abstract val countInCart: Int + abstract val price: Int + abstract val isAvailable: Boolean + abstract val isDeleted: Boolean + + open val bonuses: Int? = null + + open val variants: List = emptyList() + open val selectedVariantId: Int? = null + + val selectedVariant get() = variants.find { it.id == selectedVariantId } + + abstract fun copyWith( + countInCart: Int = this.countInCart, + isDeleted: Boolean = this.isDeleted, + selectedVariantId: Int? = this.selectedVariantId + ): TProduct + + @Suppress("UNCHECKED_CAST") + fun asProduct(): TProduct = this as TProduct +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt new file mode 100644 index 0000000..aab308d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/models/PromocodeModel.kt @@ -0,0 +1,19 @@ +package ru.touchin.roboswag.cart_utils.models + +open class PromocodeModel( + val code: String, + val discount: PromocodeDiscount, +) + +abstract class PromocodeDiscount { + + abstract fun applyTo(totalPrice: Int): Int + + class ByValue(private val value: Int) : PromocodeDiscount() { + override fun applyTo(totalPrice: Int): Int = totalPrice - value + } + + class ByPercent(private val percent: Int) : PromocodeDiscount() { + override fun applyTo(totalPrice: Int): Int = totalPrice - totalPrice * percent / 100 + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt new file mode 100644 index 0000000..149a960 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/IRemoteCartRepository.kt @@ -0,0 +1,19 @@ +package ru.touchin.roboswag.cart_utils.repositories + +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel + +/** + * Interface for server-side cart repository where each request should return updated [CartModel] + */ +interface IRemoteCartRepository, TProduct : ProductModel> { + + suspend fun getCart(): TCart + + suspend fun addProduct(product: TProduct): TCart + + suspend fun removeProduct(id: Int): TCart + + suspend fun editProductCount(id: Int, count: Int): TCart + +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt new file mode 100644 index 0000000..9746e47 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/repositories/LocalCartRepository.kt @@ -0,0 +1,96 @@ +package ru.touchin.roboswag.cart_utils.repositories + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel +import ru.touchin.roboswag.cart_utils.models.PromocodeModel + +/** + * Class that contains StateFlow of current [CartModel] which can be subscribed in ViewModels + */ +class LocalCartRepository, TProduct : ProductModel>( + initialCart: TCart +) { + + private val _currentCart = MutableStateFlow(initialCart) + val currentCart = _currentCart.asStateFlow() + + fun updateCart(cart: TCart) { + _currentCart.value = cart + } + + fun addProduct(product: TProduct) { + updateCartProducts { + add(product) + } + } + + fun removeProduct(id: Int) { + updateCartProducts { + remove(find { it.id == id }) + } + } + + fun editProductCount(id: Int, count: Int) { + updateCartProducts { + updateProduct(id) { copyWith(countInCart = count) } + } + } + + fun markProductDeleted(id: Int) { + updateCartProducts { + updateProduct(id) { copyWith(isDeleted = true) } + } + } + + fun restoreDeletedProduct(id: Int) { + updateCartProducts { + updateProduct(id) { copyWith(isDeleted = false) } + } + } + + fun applyPromocode(promocode: PromocodeModel) { + updatePromocodeList { add(promocode) } + } + + fun removePromocode(code: String) { + updatePromocodeList { removeAt(indexOfFirst { it.code == code }) } + } + + fun useBonuses(bonuses: Int) { + require(currentCart.value.availableBonuses >= bonuses) { "Can't use bonuses more than available" } + _currentCart.update { it.copyWith(usedBonuses = bonuses) } + } + + fun chooseVariant(productId: Int, variantId: Int?) { + updateCartProducts { + updateProduct(productId) { + if (variantId != null) { + check(variants.any { it.id == variantId }) { + "Product with id=$productId doesn't have variant with id=$variantId" + } + } + copyWith(selectedVariantId = variantId) + } + } + } + + private fun updateCartProducts(updateAction: MutableList.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(products = cart.products.toMutableList().apply(updateAction)) + } + } + + private fun updatePromocodeList(updateAction: MutableList.() -> Unit) { + _currentCart.update { cart -> + cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction)) + } + } + + private fun MutableList.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) { + val index = indexOfFirst { it.id == id } + if (index >= 0) this[index] = updateAction.invoke(this[index]) + } +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt new file mode 100644 index 0000000..d84a95d --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/requests_qeue/RequestsQueue.kt @@ -0,0 +1,39 @@ +package ru.touchin.roboswag.cart_utils.requests_qeue + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach + +/** + * Queue for abstract requests which will be executed one after another + */ +typealias Request = suspend () -> TResponse + +class RequestsQueue> { + + private val requestChannel = Channel(capacity = Channel.BUFFERED) + + fun initRequestsExecution( + coroutineScope: CoroutineScope, + executeRequestAction: suspend (TRequest) -> Unit, + ) { + requestChannel + .consumeAsFlow() + .onEach { executeRequestAction.invoke(it) } + .launchIn(coroutineScope) + } + + fun addToQueue(request: TRequest) { + requestChannel.trySend(request) + } + + fun clearQueue() { + while (hasPendingRequests()) requestChannel.tryReceive() + } + + @OptIn(ExperimentalCoroutinesApi::class) + fun hasPendingRequests() = !requestChannel.isEmpty +} diff --git a/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt new file mode 100644 index 0000000..41b5a88 --- /dev/null +++ b/cart-utils/src/main/java/ru/touchin/roboswag/cart_utils/update_manager/CartUpdateManager.kt @@ -0,0 +1,110 @@ +package ru.touchin.roboswag.cart_utils.update_manager + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import ru.touchin.roboswag.cart_utils.models.CartModel +import ru.touchin.roboswag.cart_utils.models.ProductModel +import ru.touchin.roboswag.cart_utils.repositories.IRemoteCartRepository +import ru.touchin.roboswag.cart_utils.repositories.LocalCartRepository +import ru.touchin.roboswag.cart_utils.requests_qeue.Request +import ru.touchin.roboswag.cart_utils.requests_qeue.RequestsQueue + +/** + * Combines local and remote cart update actions + */ +open class CartUpdateManager, TProduct : ProductModel>( + private val localCartRepository: LocalCartRepository, + private val remoteCartRepository: IRemoteCartRepository, + private val maxRequestAttemptsCount: Int = MAX_REQUEST_CART_ATTEMPTS_COUNT, + private val errorHandler: (Throwable) -> Unit = {}, +) { + + companion object { + private const val MAX_REQUEST_CART_ATTEMPTS_COUNT = 3 + } + + private val requestsQueue = RequestsQueue>() + + @Volatile + var lastRemoteCart: TCart? = null + private set + + fun initCartRequestsQueue( + coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), + ) { + requestsQueue.initRequestsExecution(coroutineScope) { request -> + runCatching { + lastRemoteCart = request.invoke() + if (!requestsQueue.hasPendingRequests()) updateLocalCartWithRemote() + }.onFailure { error -> + errorHandler.invoke(error) + requestsQueue.clearQueue() + tryToGetRemoteCartAgain() + } + } + } + + open fun addProduct(product: TProduct, restoreDeleted: Boolean = false) { + with(localCartRepository) { + if (restoreDeleted) restoreDeletedProduct(product.id) else addProduct(product) + } + + requestsQueue.addToQueue { + remoteCartRepository.addProduct(product) + } + } + + open fun removeProduct(id: Int, markDeleted: Boolean = false) { + with(localCartRepository) { + if (markDeleted) markProductDeleted(id) else removeProduct(id) + } + + requestsQueue.addToQueue { + remoteCartRepository.removeProduct(id) + } + } + + open fun editProductCount(id: Int, count: Int) { + localCartRepository.editProductCount(id, count) + + requestsQueue.addToQueue { + remoteCartRepository.editProductCount(id, count) + } + } + + private suspend fun tryToGetRemoteCartAgain() { + repeat(maxRequestAttemptsCount) { + runCatching { + lastRemoteCart = remoteCartRepository.getCart() + updateLocalCartWithRemote() + + return + } + } + } + + private fun updateLocalCartWithRemote() { + val remoteCart = lastRemoteCart ?: return + val remoteProducts = remoteCart.products + val localProducts = localCartRepository.currentCart.value.products + + val newProductsFromRemoteCart = remoteProducts.filter { remoteProduct -> + localProducts.none { it.id == remoteProduct.id } + } + + val mergedProducts = localProducts.mapNotNull { localProduct -> + val sameRemoteProduct = remoteProducts.find { it.id == localProduct.id } + + when { + sameRemoteProduct != null -> sameRemoteProduct + localProduct.isDeleted -> localProduct + else -> null + } + } + + val mergedCart = remoteCart.copyWith(products = mergedProducts + newProductsFromRemoteCart) + localCartRepository.updateCart(mergedCart) + } + +}