Skip to content

Commit ca3524b

Browse files
committed
feat: database sync management, refactor and retry logic for no connection
1 parent 12be601 commit ca3524b

File tree

5 files changed

+273
-136
lines changed

5 files changed

+273
-136
lines changed

app/src/main/java/com/patika/getir_lite/ProductViewModel.kt

Lines changed: 87 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -3,47 +3,78 @@ package com.patika.getir_lite
33
import androidx.annotation.MainThread
44
import androidx.lifecycle.ViewModel
55
import androidx.lifecycle.viewModelScope
6-
import com.patika.getir_lite.AnimationState.FINISHED
7-
import com.patika.getir_lite.AnimationState.IDLE
8-
import com.patika.getir_lite.AnimationState.OPENED
96
import com.patika.getir_lite.data.ProductRepository
7+
import com.patika.getir_lite.data.local.model.BasketWithProducts
108
import com.patika.getir_lite.model.BaseResponse
11-
import com.patika.getir_lite.model.BasketWithProducts
129
import com.patika.getir_lite.model.CountType
1310
import com.patika.getir_lite.model.Order
1411
import com.patika.getir_lite.model.ProductEvent
1512
import com.patika.getir_lite.model.ProductWithCount
16-
import com.patika.getir_lite.util.TopLevelException
13+
import com.patika.getir_lite.util.TopLevelException.GenericException
14+
import com.patika.getir_lite.util.TopLevelException.NoConnectionException
15+
import com.patika.getir_lite.util.TopLevelException.ProductNotLoadedException
1716
import dagger.hilt.android.lifecycle.HiltViewModel
18-
import kotlinx.coroutines.channels.Channel
17+
import kotlinx.coroutines.Job
18+
import kotlinx.coroutines.delay
1919
import kotlinx.coroutines.flow.Flow
2020
import kotlinx.coroutines.flow.SharingStarted
2121
import kotlinx.coroutines.flow.StateFlow
2222
import kotlinx.coroutines.flow.catch
23-
import kotlinx.coroutines.flow.combineTransform
24-
import kotlinx.coroutines.flow.consumeAsFlow
2523
import kotlinx.coroutines.flow.map
24+
import kotlinx.coroutines.flow.retryWhen
2625
import kotlinx.coroutines.flow.stateIn
2726
import kotlinx.coroutines.flow.transform
2827
import kotlinx.coroutines.launch
2928
import javax.inject.Inject
29+
import kotlin.math.min
30+
import kotlin.math.pow
3031

32+
/**
33+
* ViewModel responsible for managing UI-related data in a lifecycle-conscious way.
34+
* It interacts with [ProductRepository] to fetch and manage product data, handling both
35+
* ordinary product queries and specialized cases such as suggested products and basket operations.
36+
*/
3137
@HiltViewModel
32-
class ProductViewModel @Inject constructor(private val productRepository: ProductRepository) :
38+
class ProductViewModel @Inject constructor(
39+
private val productRepository: ProductRepository,
40+
) :
3341
ViewModel() {
3442

35-
private val actionCompletionSignal = Channel<AnimationState>(Channel.UNLIMITED)
36-
43+
/**
44+
* A [StateFlow] of [BaseResponse] that provides a stream of [List] of [ProductWithCount].
45+
* It manages the fetching and updating of product data, handling loading states, success,
46+
* errors due to product not being loaded, and connectivity issues.
47+
*
48+
* @property products The flow of product data encapsulated in [BaseResponse], which can
49+
* either be a successful data load ([BaseResponse.Success]), an error ([BaseResponse.Error]),
50+
* or a loading state ([BaseResponse.Loading]).
51+
*/
3752
val products: StateFlow<BaseResponse<List<ProductWithCount>>> = productRepository
3853
.getProductsAsFlow()
3954
.transform {
55+
val state = productRepository.getStatus().firstOrNull()
4056
when {
41-
it.isEmpty() -> emit(BaseResponse.Loading)
42-
else -> emit(BaseResponse.Success(it))
57+
state == null -> emit(BaseResponse.Loading)
58+
!state.isProductLoaded -> throw ProductNotLoadedException()
59+
state.isProductLoaded -> emit(BaseResponse.Success(it))
60+
else -> emit(BaseResponse.Loading)
61+
}
62+
}
63+
.retryWhen { cause, attempt ->
64+
if (cause is ProductNotLoadedException && attempt < MAX_RETRIES) {
65+
val delayTime = calculateDelay(attempt)
66+
if (attempt > 0) emit(BaseResponse.Error(NoConnectionException(delayTime)))
67+
productRepository.fetchDataFromRemote()
68+
delay(delayTime)
69+
true
70+
} else {
71+
false
4372
}
4473
}
4574
.catch { error ->
46-
emit(BaseResponse.Error(TopLevelException.GenericException(error.message)))
75+
if (error !is ProductNotLoadedException) {
76+
emit(BaseResponse.Error(GenericException(error.message)))
77+
}
4778
}
4879
.stateIn(
4980
scope = viewModelScope,
@@ -53,22 +84,28 @@ class ProductViewModel @Inject constructor(private val productRepository: Produc
5384

5485
val suggestedProducts = productRepository
5586
.getSuggestedProductsAsFlow()
56-
.combineTransform(actionCompletionSignal.consumeAsFlow()) { suggestedProducts, completionSignal ->
57-
when (completionSignal) {
58-
FINISHED -> {
59-
emit(BaseResponse.Success(suggestedProducts))
60-
actionCompletionSignal.send(IDLE)
61-
}
62-
63-
OPENED -> {
64-
emit(BaseResponse.Success(suggestedProducts))
65-
}
66-
87+
.transform {
88+
val state = productRepository.getStatus().firstOrNull()
89+
when {
90+
state == null -> emit(BaseResponse.Loading)
91+
!state.isSuggestedProductLoaded -> throw ProductNotLoadedException()
92+
state.isSuggestedProductLoaded -> emit(BaseResponse.Success(it))
6793
else -> emit(BaseResponse.Loading)
6894
}
6995
}
96+
.retryWhen { cause, attempt ->
97+
if (cause is ProductNotLoadedException && attempt < MAX_RETRIES) {
98+
if (attempt > 0) productRepository.fetchDataFromRemote()
99+
delay(calculateDelay(attempt))
100+
true
101+
} else {
102+
false
103+
}
104+
}
70105
.catch { error ->
71-
emit(BaseResponse.Error(TopLevelException.GenericException(error.message)))
106+
if (error !is ProductNotLoadedException) {
107+
emit(BaseResponse.Error(GenericException(error.message)))
108+
}
72109
}
73110
.stateIn(
74111
scope = viewModelScope,
@@ -80,7 +117,7 @@ class ProductViewModel @Inject constructor(private val productRepository: Produc
80117
.getBasketAsFlow()
81118
.map<Order?, BaseResponse<Order?>> { BaseResponse.Success(it) }
82119
.catch { cause ->
83-
emit(BaseResponse.Error(TopLevelException.GenericException(cause.message)))
120+
emit(BaseResponse.Error(GenericException(cause.message)))
84121
}
85122
.stateIn(
86123
scope = viewModelScope,
@@ -97,7 +134,7 @@ class ProductViewModel @Inject constructor(private val productRepository: Produc
97134
}
98135
}
99136
.catch { error ->
100-
emit(BaseResponse.Error(TopLevelException.GenericException(error.message)))
137+
emit(BaseResponse.Error(GenericException(error.message)))
101138
}
102139
.stateIn(
103140
scope = viewModelScope,
@@ -107,37 +144,46 @@ class ProductViewModel @Inject constructor(private val productRepository: Produc
107144

108145
@MainThread
109146
fun initializeProductData() = viewModelScope.launch {
110-
actionCompletionSignal.trySend(OPENED)
111-
productRepository.syncWithRemote()
147+
productRepository.fetchDataFromRemote()
112148
}
113149

150+
151+
private var job: Job? = null
152+
153+
/**
154+
* Handles incoming events related to product interactions, such as add or remove actions.
155+
*
156+
* @param event The product event to handle.
157+
*/
114158
fun onEvent(event: ProductEvent) {
115-
viewModelScope.launch {
159+
job?.cancel()
160+
job = viewModelScope.launch {
116161
when (event) {
117162
is ProductEvent.OnDeleteClick -> {
118163
productRepository.updateItemCount(event.entityId, CountType.MINUS_ONE)
119164
}
120165

121166
is ProductEvent.OnMinusClick -> {
122167
productRepository.updateItemCount(event.entityId, CountType.MINUS_ONE)
123-
if (event.count > 1) notifyActionCompleted(OPENED)
124168
}
125169

126170
is ProductEvent.OnPlusClick -> {
127171
productRepository.updateItemCount(event.entityId, CountType.PLUS_ONE)
128-
if (event.count >= 1) notifyActionCompleted(OPENED)
129172
}
130173
}
131174
}
132175
}
133176

134-
fun notifyActionCompleted(animationState: AnimationState) {
135-
viewModelScope.launch {
136-
actionCompletionSignal.send(animationState)
137-
}
177+
companion object {
178+
private const val MAX_RETRIES = 50
179+
private const val RETRY_DELAY_MS = 2000L
180+
private const val MAX_DELAY_MS = 30000L
181+
val calculateDelay: (Long) -> Long =
182+
{ attempt: Long ->
183+
min(
184+
MAX_DELAY_MS,
185+
RETRY_DELAY_MS * (2.0.pow(attempt.toDouble())).toLong()
186+
)
187+
}
138188
}
139189
}
140-
141-
enum class AnimationState {
142-
IDLE, FINISHED, OPENED
143-
}

app/src/main/java/com/patika/getir_lite/data/ProductDataSource.kt

Lines changed: 68 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,19 @@
11
package com.patika.getir_lite.data
22

3-
import android.util.Log
43
import com.patika.getir_lite.data.di.AppDispatchers.IO
54
import com.patika.getir_lite.data.di.Dispatcher
65
import com.patika.getir_lite.data.local.ProductDao
6+
import com.patika.getir_lite.data.local.model.BasketWithProducts
77
import com.patika.getir_lite.data.local.model.OrderEntity
88
import com.patika.getir_lite.data.local.model.OrderStatus
99
import com.patika.getir_lite.data.local.model.ProductEntity
10+
import com.patika.getir_lite.data.local.model.StatusEntity
1011
import com.patika.getir_lite.data.local.model.toDomainModel
1112
import com.patika.getir_lite.data.remote.RemoteRepository
1213
import com.patika.getir_lite.data.remote.model.ProductDto
1314
import com.patika.getir_lite.data.remote.model.SuggestedProductDto
1415
import com.patika.getir_lite.data.remote.model.toProductEntity
1516
import com.patika.getir_lite.model.BaseResponse
16-
import com.patika.getir_lite.model.BasketWithProducts
1717
import com.patika.getir_lite.model.CountType
1818
import com.patika.getir_lite.model.CountType.MINUS_ONE
1919
import com.patika.getir_lite.model.CountType.PLUS_ONE
@@ -24,56 +24,80 @@ import com.patika.getir_lite.util.TopLevelException
2424
import kotlinx.coroutines.CoroutineDispatcher
2525
import kotlinx.coroutines.async
2626
import kotlinx.coroutines.flow.Flow
27-
import kotlinx.coroutines.flow.MutableStateFlow
28-
import kotlinx.coroutines.flow.asStateFlow
2927
import kotlinx.coroutines.flow.map
30-
import kotlinx.coroutines.flow.update
28+
import kotlinx.coroutines.joinAll
29+
import kotlinx.coroutines.launch
3130
import kotlinx.coroutines.withContext
3231
import javax.inject.Inject
3332

33+
/**
34+
* A data source class that handles fetching, caching, and retrieving products and orders from both local and remote sources.
35+
* This class serves as an implementation of the [ProductRepository] to provide an interface for data operations.
36+
*
37+
* @property remoteRepository The backend API repository used for fetching products from a remote server.
38+
* @property productDao The local DAO (Data Access Object) used for querying and updating the local database.
39+
* @property ioDispatcher A [CoroutineDispatcher] specifically for I/O operations to ensure database and network operations do not block the main thread.
40+
*/
3441
class ProductDataSource @Inject constructor(
3542
private val remoteRepository: RemoteRepository,
3643
private val productDao: ProductDao,
3744
@Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher
3845
) : ProductRepository {
39-
40-
private val _dataSyncResult = MutableStateFlow(listOf(DataSyncResult.IDLE))
41-
override val dataSyncResult = _dataSyncResult.asStateFlow()
42-
4346
override fun getProductsAsFlow(): Flow<List<ProductWithCount>> =
4447
productDao.getProductsWithCounts(ProductType.PRODUCT)
4548

4649
override fun getSuggestedProductsAsFlow(): Flow<List<ProductWithCount>> =
4750
productDao.getProductsWithCounts(ProductType.SUGGESTED_PRODUCT)
4851

49-
override suspend fun syncWithRemote(): BaseResponse<Unit> {
50-
return try {
51-
withContext(ioDispatcher) {
52-
val remoteProductsDeferred = async { getProducts() }
53-
val remoteSuggestedProductsDeferred = async { getSuggestedProducts() }
54-
val localProductsDeferred = async { productDao.getAllItems() }
55-
56-
val remoteProducts = remoteProductsDeferred.await()
57-
val remoteSuggestedProducts = remoteSuggestedProductsDeferred.await()
58-
val localProduct = localProductsDeferred.await()
59-
60-
if (localProduct.isEmpty()) {
61-
productDao.insertOrder(OrderEntity(-1))
62-
if (remoteProducts is BaseResponse.Success) {
63-
saveDataToLocalFirstTime(remoteProducts.data)
64-
_dataSyncResult.update { it + DataSyncResult.PRODUCT_SYNCED }
65-
}
66-
67-
if (remoteSuggestedProducts is BaseResponse.Success) {
68-
saveDataToLocalFirstTime(remoteSuggestedProducts.data)
69-
_dataSyncResult.update { it + DataSyncResult.SUGGESTED_PRODUCT_SYNCED }
70-
}
71-
}
72-
73-
BaseResponse.Success(Unit)
52+
override suspend fun fetchDataFromRemote(): BaseResponse<Unit> = try {
53+
withContext(ioDispatcher) {
54+
val dbStatus = productDao.getStatus().firstOrNull() ?: run {
55+
val id = productDao.insertStatus(StatusEntity())
56+
StatusEntity(id = id)
57+
}
58+
59+
if (dbStatus.isProductLoaded && dbStatus.isSuggestedProductLoaded)
60+
return@withContext BaseResponse.Success(Unit)
61+
62+
val proJob = launch {
63+
if (!dbStatus.isProductLoaded) insertProductToDb()
64+
}
65+
66+
val suggestedProJob = launch {
67+
if (!dbStatus.isSuggestedProductLoaded) insertSuggestedProductToDb()
68+
}
69+
70+
joinAll(proJob, suggestedProJob)
71+
72+
BaseResponse.Success(Unit)
73+
}
74+
} catch (e: Exception) {
75+
BaseResponse.Error(TopLevelException.GenericException(e.message))
76+
}
77+
78+
private suspend fun insertProductToDb() {
79+
when (val remoteProducts = getProducts()) {
80+
is BaseResponse.Error -> throw remoteProducts.exception
81+
BaseResponse.Loading -> Unit
82+
is BaseResponse.Success -> {
83+
productDao.insertProductsFirstTime(
84+
data = remoteProducts.data,
85+
productType = ProductType.PRODUCT
86+
)
87+
}
88+
}
89+
}
90+
91+
private suspend fun insertSuggestedProductToDb() {
92+
when (val remoteSuggestedProducts = getSuggestedProducts()) {
93+
is BaseResponse.Error -> throw remoteSuggestedProducts.exception
94+
BaseResponse.Loading -> Unit
95+
is BaseResponse.Success -> {
96+
productDao.insertProductsFirstTime(
97+
data = remoteSuggestedProducts.data,
98+
productType = ProductType.SUGGESTED_PRODUCT
99+
)
74100
}
75-
} catch (e: Exception) {
76-
BaseResponse.Error(TopLevelException.GenericException(e.message))
77101
}
78102
}
79103

@@ -110,31 +134,26 @@ class ProductDataSource @Inject constructor(
110134
BaseResponse.Loading -> BaseResponse.Loading
111135
}
112136

113-
private suspend fun saveDataToLocalFirstTime(products: List<ProductEntity>) {
114-
productDao.insertProducts(products)
115-
}
116-
117-
override suspend fun updateItemCount(productId: Long, countType: CountType) = try {
137+
override suspend fun updateItemCount(productId: Long, countType: CountType) = runCatching {
118138
withContext(ioDispatcher) {
119-
val getActiveOrder = productDao.getActiveOrder(OrderStatus.ON_BASKET)
120-
val productPrice = productDao.getProductById(productId).price
121-
val orderId = getActiveOrder?.id ?: run {
139+
val getActiveOrder = async { productDao.getActiveOrder(OrderStatus.ON_BASKET) }
140+
val productPrice = async { productDao.getProductById(productId).price }
141+
val orderId = getActiveOrder.await()?.id ?: run {
122142
val id = productDao.insertOrder(
123143
OrderEntity(orderStatus = OrderStatus.ON_BASKET)
124144
)
125145
id
126146
}
127147

128148
when (countType) {
129-
PLUS_ONE -> productDao.addItemToBasket(productId, orderId, productPrice)
130-
MINUS_ONE -> productDao.decrementItemCount(productId, orderId, productPrice)
149+
PLUS_ONE -> productDao.addItemToBasket(productId, orderId, productPrice.await())
150+
MINUS_ONE -> productDao.decrementItemCount(productId, orderId, productPrice.await())
131151
}
132152
}
133-
} catch (e: Exception) {
134-
Log.e("ProductDataSource", "updateItemCount: $e")
135-
Unit
136153
}
137154

155+
override suspend fun getStatus(): List<StatusEntity?> = productDao.getStatus()
156+
138157
override suspend fun clearBasket(): Boolean = try {
139158
withContext(ioDispatcher) {
140159
productDao.cancelOrder()
@@ -144,7 +163,3 @@ class ProductDataSource @Inject constructor(
144163
false
145164
}
146165
}
147-
148-
enum class DataSyncResult {
149-
PRODUCT_SYNCED, SUGGESTED_PRODUCT_SYNCED, IDLE
150-
}

0 commit comments

Comments
 (0)