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

Commit 974f26c

Browse files
authored
Merge pull request #82 from Automattic/adam/GRA-593
Store private phone&email locally
2 parents c8fda59 + a3db22e commit 974f26c

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()
@@ -71,6 +83,17 @@ internal class ShareViewModel(
7183
}
7284
}
7385

86+
private fun savePrivateContactInfo() {
87+
// Cancel any existing job to avoid multiple saves
88+
saveContactInfoJob?.cancel()
89+
90+
// Create a new job with debounce
91+
saveContactInfoJob = viewModelScope.launch {
92+
delay(debounceDelay) // Wait for the debounce period
93+
updatePrivateContactInfo(_uiState.value.privateContactInfo)
94+
}
95+
}
96+
7497
private fun showAboutAppDialog() {
7598
_uiState.update { currentState ->
7699
currentState.copy(isAboutAppDialogVisible = true)
@@ -130,4 +153,16 @@ internal class ShareViewModel(
130153
}
131154
.launchIn(viewModelScope)
132155
}
156+
157+
private fun collectPrivateContactInfo() {
158+
getPrivateContactInfo()
159+
.onEach { privateContactInfo ->
160+
_uiState.update { currentState ->
161+
currentState.copy(
162+
privateContactInfo = privateContactInfo
163+
)
164+
}
165+
}
166+
.launchIn(viewModelScope)
167+
}
133168
}

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,25 @@ 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+
324363
@Test
325364
fun `when OnPrivateInformationClicked event is triggered then isPrivateInformationDialogVisible is set to true`() = runTest {
326365
// When
@@ -352,6 +391,109 @@ class ShareViewModelTest {
352391
}
353392
}
354393

394+
@Test
395+
fun `when OnEmailValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
396+
// Given
397+
val newEmailValue = "test@example.com"
398+
399+
privateContactInfoFlow.test {
400+
// When
401+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
402+
403+
// Then
404+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
405+
privateEmail = newEmailValue
406+
)
407+
assertEquals(expectedPrivateContactInfo, awaitItem())
408+
}
409+
}
410+
411+
@Test
412+
fun `when OnPhoneValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
413+
// Given
414+
val newPhoneValue = "123-456-7890"
415+
416+
privateContactInfoFlow.test {
417+
// When
418+
viewModel.onEvent(ShareEvent.OnPhoneValueChanged(newPhoneValue))
419+
420+
// Then
421+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
422+
privatePhone = newPhoneValue
423+
)
424+
assertEquals(expectedPrivateContactInfo, awaitItem())
425+
}
426+
}
427+
428+
@Test
429+
fun `when email value is changed then updatePrivateContactInfo is not called immediately`() = runTest {
430+
// Given
431+
val newEmailValue = "test@example.com"
432+
433+
privateContactInfoFlow.test {
434+
// When
435+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
436+
437+
// Advance time by just under the debounce delay
438+
advanceTimeBy(499) // Just under the 500ms debounce delay
439+
440+
// Then - no emissions yet because of debounce
441+
expectNoEvents()
442+
cancel()
443+
}
444+
}
445+
446+
@Test
447+
fun `when email value is changed then updatePrivateContactInfo is called after debounce delay`() = runTest {
448+
// Given
449+
val newEmailValue = "test@example.com"
450+
451+
privateContactInfoFlow.test {
452+
// When
453+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))
454+
455+
// Advance time past the debounce delay
456+
advanceTimeBy(501) // Just past the 500ms debounce delay
457+
458+
// Then
459+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
460+
privateEmail = newEmailValue
461+
)
462+
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())
463+
cancel()
464+
}
465+
}
466+
467+
@Test
468+
fun `when multiple rapid email value changes occur then only the last one triggers updatePrivateContactInfo`() = runTest {
469+
// Given
470+
val firstEmailValue = "first@example.com"
471+
val secondEmailValue = "second@example.com"
472+
val thirdEmailValue = "third@example.com"
473+
474+
privateContactInfoFlow.test {
475+
// When - rapid changes
476+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(firstEmailValue))
477+
advanceTimeBy(100) // Not enough time for debounce
478+
479+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(secondEmailValue))
480+
advanceTimeBy(100) // Not enough time for debounce
481+
482+
viewModel.onEvent(ShareEvent.OnEmailValueChanged(thirdEmailValue))
483+
484+
// Advance time past the debounce delay
485+
advanceTimeBy(501) // Just past the 500ms debounce delay
486+
487+
// Then - only the last value should be emitted
488+
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
489+
privateEmail = thirdEmailValue
490+
)
491+
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())
492+
493+
cancel()
494+
}
495+
}
496+
355497
private fun createTestProfile() = Profile {
356498
hash = "test-hash"
357499
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)