Merge branch 'cart_utils' into 'master'
Cart utils See merge request touchinstinct/RoboSwag!3
This commit is contained in:
commit
54d2482064
|
|
@ -0,0 +1 @@
|
||||||
|
/build
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
<manifest package="ru.touchin.roboswag.core.cart_utils" />
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue