Skip to content

Commit feef79e

Browse files
committed
fix: update activity reactively
1 parent 27e33a8 commit feef79e

File tree

2 files changed

+171
-0
lines changed

2 files changed

+171
-0
lines changed

app/src/main/java/to/bitkit/viewmodels/ActivityDetailViewModel.kt

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import com.synonym.bitkitcore.IBtOrder
88
import dagger.hilt.android.lifecycle.HiltViewModel
99
import dagger.hilt.android.qualifiers.ApplicationContext
1010
import kotlinx.coroutines.CoroutineDispatcher
11+
import kotlinx.coroutines.Job
1112
import kotlinx.coroutines.flow.MutableStateFlow
1213
import kotlinx.coroutines.flow.StateFlow
1314
import kotlinx.coroutines.flow.asStateFlow
@@ -45,6 +46,7 @@ class ActivityDetailViewModel @Inject constructor(
4546
val boostSheetVisible = _boostSheetVisible.asStateFlow()
4647

4748
private var activity: Activity? = null
49+
private var observeJob: Job? = null
4850

4951
private val _uiState = MutableStateFlow(ActivityDetailUiState())
5052
val uiState: StateFlow<ActivityDetailUiState> = _uiState.asStateFlow()
@@ -64,6 +66,7 @@ class ActivityDetailViewModel @Inject constructor(
6466
this@ActivityDetailViewModel.activity = activity
6567
_uiState.update { it.copy(activityLoadState = ActivityLoadState.Success(activity)) }
6668
loadTags()
69+
observeActivityChanges(activityId)
6770
} else {
6871
_uiState.update {
6972
it.copy(
@@ -88,8 +91,37 @@ class ActivityDetailViewModel @Inject constructor(
8891
}
8992

9093
fun clearActivityState() {
94+
observeJob?.cancel()
95+
observeJob = null
9196
_uiState.update { it.copy(activityLoadState = ActivityLoadState.Initial) }
9297
activity = null
98+
_tags.value = emptyList()
99+
}
100+
101+
private fun observeActivityChanges(activityId: String) {
102+
observeJob?.cancel()
103+
observeJob = viewModelScope.launch(bgDispatcher) {
104+
activityRepo.activitiesChanged.collect {
105+
reloadActivity(activityId)
106+
}
107+
}
108+
}
109+
110+
private suspend fun reloadActivity(activityId: String) {
111+
activityRepo.getActivity(activityId)
112+
.onSuccess { updatedActivity ->
113+
if (updatedActivity != null) {
114+
activity = updatedActivity
115+
_uiState.update {
116+
it.copy(activityLoadState = ActivityLoadState.Success(updatedActivity))
117+
}
118+
loadTags()
119+
}
120+
}
121+
.onFailure { error ->
122+
Logger.warn("Failed to reload activity $activityId", error, context = TAG)
123+
// Keep showing last known state on reload failure
124+
}
93125
}
94126

95127
fun loadTags() {

app/src/test/java/to/bitkit/repositories/ActivityDetailViewModelTest.kt

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,27 @@
11
package to.bitkit.repositories
22

3+
import android.content.Context
4+
import com.synonym.bitkitcore.Activity
35
import com.synonym.bitkitcore.IBtOrder
6+
import com.synonym.bitkitcore.OnchainActivity
7+
import com.synonym.bitkitcore.PaymentType
48
import kotlinx.coroutines.flow.MutableStateFlow
59
import org.junit.Before
610
import org.junit.Test
711
import org.mockito.kotlin.doReturn
812
import org.mockito.kotlin.mock
913
import org.mockito.kotlin.whenever
14+
import to.bitkit.R
1015
import to.bitkit.data.SettingsStore
1116
import to.bitkit.test.BaseUnitTest
1217
import to.bitkit.viewmodels.ActivityDetailViewModel
1318
import kotlin.test.assertEquals
1419
import kotlin.test.assertNull
20+
import kotlin.test.assertTrue
1521

1622
class ActivityDetailViewModelTest : BaseUnitTest() {
1723

24+
private val context = mock<Context>()
1825
private val activityRepo = mock<ActivityRepo>()
1926
private val blocktankRepo = mock<BlocktankRepo>()
2027
private val settingsStore = mock<SettingsStore>()
@@ -24,9 +31,15 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
2431

2532
@Before
2633
fun setUp() {
34+
whenever(context.getString(R.string.wallet__activity_error_not_found))
35+
.thenReturn("Activity not found")
36+
whenever(context.getString(R.string.wallet__activity_error_load_failed))
37+
.thenReturn("Failed to load activity")
2738
whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState()))
39+
whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(System.currentTimeMillis()))
2840

2941
sut = ActivityDetailViewModel(
42+
context = context,
3043
bgDispatcher = testDispatcher,
3144
activityRepo = activityRepo,
3245
blocktankRepo = blocktankRepo,
@@ -84,4 +97,130 @@ class ActivityDetailViewModelTest : BaseUnitTest() {
8497

8598
assertNull(result)
8699
}
100+
101+
@Test
102+
fun `loadActivity starts observation of activity changes`() = test {
103+
val activityId = "test-activity-1"
104+
val initialActivity = createTestActivity(activityId, confirmed = false)
105+
val updatedActivity = createTestActivity(activityId, confirmed = true)
106+
val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis())
107+
108+
whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow)
109+
whenever(activityRepo.getActivity(activityId))
110+
.thenReturn(Result.success(initialActivity))
111+
whenever(activityRepo.getActivityTags(activityId))
112+
.thenReturn(Result.success(emptyList()))
113+
114+
// Load activity
115+
sut.loadActivity(activityId)
116+
117+
// Verify initial state loaded
118+
val initialState = sut.uiState.value.activityLoadState
119+
assertTrue(initialState is ActivityDetailViewModel.ActivityLoadState.Success)
120+
assertEquals(initialActivity, (initialState as ActivityDetailViewModel.ActivityLoadState.Success).activity)
121+
122+
// Simulate activity update
123+
whenever(activityRepo.getActivity(activityId))
124+
.thenReturn(Result.success(updatedActivity))
125+
activitiesChangedFlow.value = System.currentTimeMillis()
126+
127+
// Verify ViewModel reflects updated activity
128+
val updatedState = sut.uiState.value.activityLoadState
129+
assertTrue(updatedState is ActivityDetailViewModel.ActivityLoadState.Success)
130+
assertEquals(updatedActivity, (updatedState as ActivityDetailViewModel.ActivityLoadState.Success).activity)
131+
}
132+
133+
@Test
134+
fun `clearActivityState stops observation`() = test {
135+
val activityId = "test-activity-1"
136+
val activity = createTestActivity(activityId)
137+
val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis())
138+
139+
whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow)
140+
whenever(activityRepo.getActivity(activityId))
141+
.thenReturn(Result.success(activity))
142+
whenever(activityRepo.getActivityTags(activityId))
143+
.thenReturn(Result.success(emptyList()))
144+
145+
// Load activity
146+
sut.loadActivity(activityId)
147+
148+
// Clear state
149+
sut.clearActivityState()
150+
151+
// Trigger activity change
152+
val callCountBefore = org.mockito.kotlin.mockingDetails(activityRepo).invocations.size
153+
activitiesChangedFlow.value = System.currentTimeMillis()
154+
155+
// Verify no reload after clear (getActivity not called again)
156+
val callCountAfter = org.mockito.kotlin.mockingDetails(activityRepo).invocations.size
157+
assertEquals(callCountBefore, callCountAfter)
158+
}
159+
160+
@Test
161+
fun `reloadActivity keeps last state on failure`() = test {
162+
val activityId = "test-activity-1"
163+
val activity = createTestActivity(activityId)
164+
val activitiesChangedFlow = MutableStateFlow(System.currentTimeMillis())
165+
166+
whenever(activityRepo.activitiesChanged).thenReturn(activitiesChangedFlow)
167+
whenever(activityRepo.getActivity(activityId))
168+
.thenReturn(Result.success(activity))
169+
whenever(activityRepo.getActivityTags(activityId))
170+
.thenReturn(Result.success(emptyList()))
171+
172+
// Load activity
173+
sut.loadActivity(activityId)
174+
175+
// Simulate reload failure
176+
whenever(activityRepo.getActivity(activityId))
177+
.thenReturn(Result.failure(Exception("Network error")))
178+
activitiesChangedFlow.value = System.currentTimeMillis()
179+
180+
// Verify last known state is preserved
181+
val state = sut.uiState.value.activityLoadState
182+
assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Success)
183+
assertEquals(activity, (state as ActivityDetailViewModel.ActivityLoadState.Success).activity)
184+
}
185+
186+
@Test
187+
fun `loadActivity handles error gracefully`() = test {
188+
val activityId = "test-activity-1"
189+
190+
whenever(activityRepo.getActivity(activityId))
191+
.thenReturn(Result.failure(Exception("Database error")))
192+
193+
sut.loadActivity(activityId)
194+
195+
val state = sut.uiState.value.activityLoadState
196+
assertTrue(state is ActivityDetailViewModel.ActivityLoadState.Error)
197+
}
198+
199+
private fun createTestActivity(
200+
id: String,
201+
confirmed: Boolean = false,
202+
): Activity.Onchain {
203+
return Activity.Onchain(
204+
v1 = OnchainActivity(
205+
id = id,
206+
txType = PaymentType.RECEIVED,
207+
txId = "tx-$id",
208+
value = 100000UL,
209+
fee = 500UL,
210+
feeRate = 8UL,
211+
address = "bc1...",
212+
confirmed = confirmed,
213+
timestamp = (System.currentTimeMillis() / 1000).toULong(),
214+
isBoosted = false,
215+
boostTxIds = emptyList(),
216+
isTransfer = false,
217+
doesExist = true,
218+
confirmTimestamp = if (confirmed) (System.currentTimeMillis() / 1000).toULong() else null,
219+
channelId = null,
220+
transferTxId = null,
221+
createdAt = null,
222+
updatedAt = null,
223+
)
224+
)
225+
}
87226
}

0 commit comments

Comments
 (0)