Merge branch 'cart_utils' into 'master'

Cart utils

See merge request touchinstinct/RoboSwag!3
This commit is contained in:
Grigorii Leontev 2023-03-23 15:19:45 +00:00
commit 54d2482064
10 changed files with 367 additions and 0 deletions

1
cart-utils/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

22
cart-utils/build.gradle Normal file
View File

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

View File

@ -0,0 +1 @@
<manifest package="ru.touchin.roboswag.core.cart_utils" />

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -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])
}
}

View File

@ -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
}

View File

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