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)
+ }
+
+}