Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.

Commit 0e9efd5

Browse files
Store private phone&email locally
1 parent 3fa5690 commit 0e9efd5

File tree

7 files changed

+261
-2
lines changed

7 files changed

+261
-2
lines changed

homeUi/src/main/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModel.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@ import androidx.lifecycle.ViewModel
44
import androidx.lifecycle.viewModelScope
55
import com.gravatar.app.usercomponent.domain.repository.UserRepository
66
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
7+
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
78
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
9+
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
810
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
11+
import kotlinx.coroutines.Job
12+
import kotlinx.coroutines.delay
913
import kotlinx.coroutines.flow.MutableStateFlow
1014
import kotlinx.coroutines.flow.StateFlow
1115
import kotlinx.coroutines.flow.asStateFlow
@@ -19,15 +23,21 @@ internal class ShareViewModel(
1923
private val getAvatarUrl: GetAvatarUrl,
2024
private val getUserSharePreferences: GetUserSharePreferences,
2125
private val updateUserSharePreferences: UpdateUserSharePreferences,
26+
private val getPrivateContactInfo: GetPrivateContactInfo,
27+
private val updatePrivateContactInfo: UpdatePrivateContactInfo,
2228
) : ViewModel() {
2329

2430
private val _uiState = MutableStateFlow(ShareUiState())
2531
internal val uiState: StateFlow<ShareUiState> = _uiState.asStateFlow()
2632

33+
private var saveContactInfoJob: Job? = null
34+
private val debounceDelay = 500L // 500ms debounce delay
35+
2736
init {
2837
collectProfile()
2938
collectAvatarUrl()
3039
collectUserSharePreferences()
40+
collectPrivateContactInfo()
3141
}
3242

3343
fun onEvent(shareEvent: ShareEvent) {
@@ -40,6 +50,7 @@ internal class ShareViewModel(
4050
)
4151
)
4252
}
53+
savePrivateContactInfo()
4354
}
4455

4556
is ShareEvent.OnPhoneValueChanged -> {
@@ -50,6 +61,7 @@ internal class ShareViewModel(
5061
)
5162
)
5263
}
64+
savePrivateContactInfo()
5365
}
5466

5567
is ShareEvent.OnAboutAppClicked -> showAboutAppDialog()
@@ -69,6 +81,17 @@ internal class ShareViewModel(
6981
}
7082
}
7183

84+
private fun savePrivateContactInfo() {
85+
// Cancel any existing job to avoid multiple saves
86+
saveContactInfoJob?.cancel()
87+
88+
// Create a new job with debounce
89+
saveContactInfoJob = viewModelScope.launch {
90+
delay(debounceDelay) // Wait for the debounce period
91+
updatePrivateContactInfo(_uiState.value.privateContactInfo)
92+
}
93+
}
94+
7295
private fun showAboutAppDialog() {
7396
_uiState.update { currentState ->
7497
currentState.copy(isAboutAppDialogVisible = true)
@@ -116,4 +139,16 @@ internal class ShareViewModel(
116139
}
117140
.launchIn(viewModelScope)
118141
}
142+
143+
private fun collectPrivateContactInfo() {
144+
getPrivateContactInfo()
145+
.onEach { privateContactInfo ->
146+
_uiState.update { currentState ->
147+
currentState.copy(
148+
privateContactInfo = privateContactInfo
149+
)
150+
}
151+
}
152+
.launchIn(viewModelScope)
153+
}
119154
}

homeUi/src/test/kotlin/com/gravatar/app/homeUi/presentation/home/share/ShareViewModelTest.kt

Lines changed: 143 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,13 @@ package com.gravatar.app.homeUi.presentation.home.share
22

33
import app.cash.turbine.test
44
import com.gravatar.app.testUtils.CoroutineTestRule
5+
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
56
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
67
import com.gravatar.app.usercomponent.domain.repository.UserRepository
78
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
9+
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
810
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
11+
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
912
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
1013
import com.gravatar.restapi.models.Profile
1114
import com.gravatar.restapi.models.ProfileContactInfo
@@ -14,6 +17,7 @@ import io.mockk.mockk
1417
import kotlinx.coroutines.ExperimentalCoroutinesApi
1518
import kotlinx.coroutines.flow.MutableSharedFlow
1619
import kotlinx.coroutines.test.StandardTestDispatcher
20+
import kotlinx.coroutines.test.advanceTimeBy
1721
import kotlinx.coroutines.test.advanceUntilIdle
1822
import kotlinx.coroutines.test.runTest
1923
import org.junit.Assert.assertEquals
@@ -43,18 +47,34 @@ class ShareViewModelTest {
4347
userSharePreferencesFlow.emit(userSharePreferences)
4448
}
4549
}
50+
private val getPrivateContactInfo: GetPrivateContactInfo = object : GetPrivateContactInfo {
51+
override fun invoke() = privateContactInfoFlow
52+
}
53+
private val updatePrivateContactInfo = object : UpdatePrivateContactInfo {
54+
override suspend fun invoke(privateContactInfo: PrivateContactInfo) {
55+
privateContactInfoFlow.emit(privateContactInfo)
56+
}
57+
}
4658
private val userRepository = mockk<UserRepository>()
4759

4860
private lateinit var viewModel: ShareViewModel
4961

5062
private val avatarUrlFlow: MutableSharedFlow<URL?> = MutableSharedFlow()
5163
private val profileFlow: MutableSharedFlow<Profile?> = MutableSharedFlow()
5264
private val userSharePreferencesFlow: MutableSharedFlow<UserSharePreferences> = MutableSharedFlow()
65+
private val privateContactInfoFlow: MutableSharedFlow<PrivateContactInfo> = MutableSharedFlow()
5366

5467
@Before
5568
fun setup() {
5669
every { userRepository.getProfile() } returns profileFlow
57-
viewModel = ShareViewModel(userRepository, getAvatarUrl, getUserSharePreferences, updateUserSharePreferences)
70+
viewModel = ShareViewModel(
71+
userRepository,
72+
getAvatarUrl,
73+
getUserSharePreferences,
74+
updateUserSharePreferences,
75+
getPrivateContactInfo,
76+
updatePrivateContactInfo
77+
)
5878
}
5979

6080
@Test
@@ -321,6 +341,128 @@ class ShareViewModelTest {
321341
}
322342
}
323343

344+
@Test
345+
fun `when private contact info is emitted then uiState is updated with private contact info`() = runTest {
346+
// Given
347+
val testPrivateContactInfo = PrivateContactInfo(
348+
privateEmail = "test@example.com",
349+
privatePhone = "123-456-7890"
350+
)
351+
352+
// When
353+
privateContactInfoFlow.emit(testPrivateContactInfo)
354+
advanceUntilIdle()
355+
356+
// Then
357+
viewModel.uiState.test {
358+
val state = awaitItem()
359+
assertEquals(testPrivateContactInfo, state.privateContactInfo)
360+
}
361+
}
362+
363+
@Test
364+
fun `when OnEmailValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
365+
// Given
366+
val newEmailValue = "test@example.com"
367+
368+
privateContactInfoFlow.test {
369+
// When
370+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
371+
372+
// Then
373+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
374+
privateEmail = newEmailValue
375+
)
376+
assertEquals(expectedPrivateContactInfo, awaitItem())
377+
}
378+
}
379+
380+
@Test
381+
fun `when OnPhoneValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
382+
// Given
383+
val newPhoneValue = "123-456-7890"
384+
385+
privateContactInfoFlow.test {
386+
// When
387+
viewModel.onEvent(ShareEvent.OnPhoneValueChanged(newPhoneValue))
388+
389+
// Then
390+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
391+
privatePhone = newPhoneValue
392+
)
393+
assertEquals(expectedPrivateContactInfo, awaitItem())
394+
}
395+
}
396+
397+
@Test
398+
fun `when email value is changed then updatePrivateContactInfo is not called immediately`() = runTest {
399+
// Given
400+
val newEmailValue = "test@example.com"
401+
402+
privateContactInfoFlow.test {
403+
// When
404+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
405+
406+
// Advance time by just under the debounce delay
407+
advanceTimeBy(499) // Just under the 500ms debounce delay
408+
409+
// Then - no emissions yet because of debounce
410+
expectNoEvents()
411+
cancel()
412+
}
413+
}
414+
415+
@Test
416+
fun `when email value is changed then updatePrivateContactInfo is called after debounce delay`() = runTest {
417+
// Given
418+
val newEmailValue = "test@example.com"
419+
420+
privateContactInfoFlow.test {
421+
// When
422+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
423+
424+
// Advance time past the debounce delay
425+
advanceTimeBy(501) // Just past the 500ms debounce delay
426+
427+
// Then
428+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
429+
privateEmail = newEmailValue
430+
)
431+
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())
432+
cancel()
433+
}
434+
}
435+
436+
@Test
437+
fun `when multiple rapid email value changes occur then only the last one triggers updatePrivateContactInfo`() = runTest {
438+
// Given
439+
val firstEmailValue = "first@example.com"
440+
val secondEmailValue = "second@example.com"
441+
val thirdEmailValue = "third@example.com"
442+
443+
privateContactInfoFlow.test {
444+
// When - rapid changes
445+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(firstEmailValue))
446+
advanceTimeBy(100) // Not enough time for debounce
447+
448+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(secondEmailValue))
449+
advanceTimeBy(100) // Not enough time for debounce
450+
451+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(thirdEmailValue))
452+
453+
// Advance time past the debounce delay
454+
advanceTimeBy(501) // Just past the 500ms debounce delay
455+
456+
// Then - only the last value should be emitted
457+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
458+
privateEmail = thirdEmailValue
459+
)
460+
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())
461+
462+
cancel()
463+
}
464+
}
465+
324466
private fun createTestProfile() = Profile {
325467
hash = "test-hash"
326468
displayName = "Test User"

userComponent/src/main/kotlin/com/gravatar/app/usercomponent/data/UserPrefsStorage.kt

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.edit
88
import androidx.datastore.preferences.core.stringPreferencesKey
99
import com.gravatar.app.foundations.DispatcherProvider
1010
import com.gravatar.app.usercomponent.di.UserPrefs
11+
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
1112
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
1213
import kotlinx.coroutines.flow.Flow
1314
import kotlinx.coroutines.flow.catch
@@ -37,10 +38,16 @@ internal interface UserSharePreferencesStorage {
3738
suspend fun saveUserSharePreferences(userSharePreferences: UserSharePreferences)
3839
}
3940

41+
internal interface PrivateContactInfoStorage {
42+
fun getPrivateContactInfo(): Flow<PrivateContactInfo>
43+
44+
suspend fun savePrivateContactInfo(privateContactInfo: PrivateContactInfo)
45+
}
46+
4047
/**
4148
* Convenient interface to clear all user related data in one call.
4249
*/
43-
internal interface UserStorage : AuthTokenStorage, AvatarCacheBusterStorage, UserSharePreferencesStorage {
50+
internal interface UserStorage : AuthTokenStorage, AvatarCacheBusterStorage, UserSharePreferencesStorage, PrivateContactInfoStorage {
4451
suspend fun clear()
4552
}
4653

@@ -60,6 +67,8 @@ internal class DatastoreUserPrefsStorage(
6067
private const val USER_SHARE_PROFILE_URL_KEY = "share_profile_url"
6168
private const val USER_SHARE_PRIVATE_EMAIL_KEY = "share_private_email"
6269
private const val USER_SHARE_PRIVATE_PHONE_KEY = "share_private_phone"
70+
private const val USER_SHARE_PRIVATE_PHONE_VALUE_KEY = "private_phone_value"
71+
private const val USER_SHARE_PRIVATE_EMAIL_VALUE_KEY = "private_email_value"
6372
}
6473

6574
private val tokenKey = stringPreferencesKey(AUTH_TOKEN_KEY)
@@ -72,6 +81,8 @@ internal class DatastoreUserPrefsStorage(
7281
private val userShareProfileUrlKey = booleanPreferencesKey(USER_SHARE_PROFILE_URL_KEY)
7382
private val userSharePrivateEmailKey = booleanPreferencesKey(USER_SHARE_PRIVATE_EMAIL_KEY)
7483
private val userSharePrivatePhoneKey = booleanPreferencesKey(USER_SHARE_PRIVATE_PHONE_KEY)
84+
private val userSharePrivatePhoneValueKey = stringPreferencesKey(USER_SHARE_PRIVATE_PHONE_VALUE_KEY)
85+
private val userSharePrivateEmailValueKey = stringPreferencesKey(USER_SHARE_PRIVATE_EMAIL_VALUE_KEY)
7586

7687
override suspend fun getToken(): String? {
7788
return try {
@@ -151,4 +162,25 @@ internal class DatastoreUserPrefsStorage(
151162
preferences[userShareProfileUrlKey] = userSharePreferences.profileUrl
152163
}
153164
}
165+
166+
override fun getPrivateContactInfo(): Flow<PrivateContactInfo> {
167+
return dataStore.data
168+
.map { preferences ->
169+
PrivateContactInfo(
170+
privateEmail = preferences[userSharePrivateEmailValueKey] ?: "",
171+
privatePhone = preferences[userSharePrivatePhoneValueKey] ?: ""
172+
)
173+
}
174+
.catch { emit(PrivateContactInfo.Default) }
175+
.flowOn(dispatcherProvider.io)
176+
}
177+
178+
override suspend fun savePrivateContactInfo(privateContactInfo: PrivateContactInfo) {
179+
withContext(dispatcherProvider.io) {
180+
dataStore.edit { preferences ->
181+
preferences[userSharePrivateEmailValueKey] = privateContactInfo.privateEmail
182+
preferences[userSharePrivatePhoneValueKey] = privateContactInfo.privatePhone
183+
}
184+
}
185+
}
154186
}

userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/DatastoreModule.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.datastore.preferences.preferencesDataStore
77
import com.gravatar.app.usercomponent.data.AuthTokenStorage
88
import com.gravatar.app.usercomponent.data.AvatarCacheBusterStorage
99
import com.gravatar.app.usercomponent.data.DatastoreUserPrefsStorage
10+
import com.gravatar.app.usercomponent.data.PrivateContactInfoStorage
1011
import com.gravatar.app.usercomponent.data.UserSharePreferencesStorage
1112
import com.gravatar.app.usercomponent.data.UserStorage
1213
import org.koin.android.ext.koin.androidContext
@@ -42,6 +43,12 @@ internal val datastoreModule = module {
4243
dispatcherProvider = get()
4344
)
4445
}
46+
factory<PrivateContactInfoStorage> {
47+
DatastoreUserPrefsStorage(
48+
dataStore = get(qualifier = named<UserPrefs>()),
49+
dispatcherProvider = get()
50+
)
51+
}
4552
}
4653

4754
private val Context.userPreferencesDataStore by preferencesDataStore(name = "user-preferences")

userComponent/src/main/kotlin/com/gravatar/app/usercomponent/di/UserComponentModule.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatar
1313
import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatarUseCase
1414
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
1515
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrlUseCase
16+
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
17+
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfoUseCase
1618
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
1719
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferencesUseCase
1820
import com.gravatar.app.usercomponent.domain.usecase.IsUserLoggedIn
@@ -23,6 +25,8 @@ import com.gravatar.app.usercomponent.domain.usecase.Logout
2325
import com.gravatar.app.usercomponent.domain.usecase.LogoutUseCase
2426
import com.gravatar.app.usercomponent.domain.usecase.SelectAvatarUseCase
2527
import com.gravatar.app.usercomponent.domain.usecase.SelectUserAvatar
28+
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
29+
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfoUseCase
2630
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
2731
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferencesUseCase
2832
import com.gravatar.app.usercomponent.domain.usecase.UploadAvatarUseCase
@@ -45,6 +49,8 @@ val userComponentModule = module {
4549
factoryOf(::UploadAvatarUseCase) { bind<UploadUserAvatar>() }
4650
factoryOf(::GetUserSharePreferencesUseCase) { bind<GetUserSharePreferences>() }
4751
factoryOf(::UpdateUserSharePreferencesUseCase) { bind<UpdateUserSharePreferences>() }
52+
factoryOf(::GetPrivateContactInfoUseCase) { bind<GetPrivateContactInfo>() }
53+
factoryOf(::UpdatePrivateContactInfoUseCase) { bind<UpdatePrivateContactInfo>() }
4854
factoryOf(::WordPressClient)
4955
singleOf(::InMemoryUserSessionPersistence) { bind<UserSessionPersistence>() }
5056
includes(httpClientModule)

0 commit comments

Comments
 (0)