Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cart-utils/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/build
22 changes: 22 additions & 0 deletions cart-utils/build.gradle
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
1 change: 1 addition & 0 deletions cart-utils/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<manifest package="ru.touchin.roboswag.core.cart_utils" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package ru.touchin.roboswag.cart_utils.models

abstract class CartModel<TProductModel : ProductModel> {

abstract val products: List<TProductModel>

open val promocodeList: List<PromocodeModel> = emptyList()

open val availableBonuses: Int = 0
open val usedBonuses: Int = 0

val availableProducts: List<TProductModel>
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 <TCart> copyWith(
products: List<TProductModel> = this.products,
promocodeList: List<PromocodeModel> = this.promocodeList,
usedBonuses: Int = this.usedBonuses
): TCart

@Suppress("UNCHECKED_CAST")
fun <TCart> asCart() = this as TCart
}
Original file line number Diff line number Diff line change
@@ -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<ProductModel> = emptyList()
open val selectedVariantId: Int? = null

val selectedVariant get() = variants.find { it.id == selectedVariantId }

abstract fun <TProduct> copyWith(
countInCart: Int = this.countInCart,
isDeleted: Boolean = this.isDeleted,
selectedVariantId: Int? = this.selectedVariantId
): TProduct

@Suppress("UNCHECKED_CAST")
fun <TProduct> asProduct(): TProduct = this as TProduct
}
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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<TCart : CartModel<TProduct>, 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

}
Original file line number Diff line number Diff line change
@@ -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<TCart : CartModel<TProduct>, 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<TProduct>.() -> Unit) {
_currentCart.update { cart ->
cart.copyWith(products = cart.products.toMutableList().apply(updateAction))
}
}

private fun updatePromocodeList(updateAction: MutableList<PromocodeModel>.() -> Unit) {
_currentCart.update { cart ->
cart.copyWith(promocodeList = cart.promocodeList.toMutableList().apply(updateAction))
}
}

private fun MutableList<TProduct>.updateProduct(id: Int, updateAction: TProduct.() -> TProduct) {
val index = indexOfFirst { it.id == id }
if (index >= 0) this[index] = updateAction.invoke(this[index])
}
}
Original file line number Diff line number Diff line change
@@ -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<TResponse> = suspend () -> TResponse

class RequestsQueue<TRequest : Request<*>> {

private val requestChannel = Channel<TRequest>(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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
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<TCart : CartModel<TProduct>, TProduct : ProductModel>(
private val localCartRepository: LocalCartRepository<TCart, TProduct>,
private val remoteCartRepository: IRemoteCartRepository<TCart, TProduct>,
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<Request<TCart>>()

@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<TCart>(products = mergedProducts + newProductsFromRemoteCart)
localCartRepository.updateCart(mergedCart)
}

}