Skip to content
This repository was archived by the owner on Aug 21, 2025. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,12 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.gravatar.app.usercomponent.domain.repository.UserRepository
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
Expand All @@ -19,15 +23,21 @@ internal class ShareViewModel(
private val getAvatarUrl: GetAvatarUrl,
private val getUserSharePreferences: GetUserSharePreferences,
private val updateUserSharePreferences: UpdateUserSharePreferences,
private val getPrivateContactInfo: GetPrivateContactInfo,
private val updatePrivateContactInfo: UpdatePrivateContactInfo,
) : ViewModel() {

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

private var saveContactInfoJob: Job? = null
private val debounceDelay = 500L // 500ms debounce delay
Copy link

Copilot AI Jul 25, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The debounce delay should be extracted as a constant at the class or companion object level, or made configurable, rather than being a magic number in the property declaration.

Suggested change
private val debounceDelay = 500L // 500ms debounce delay
companion object {
const val DEBOUNCE_DELAY = 500L // 500ms debounce delay
}

Copilot uses AI. Check for mistakes.

init {
collectProfile()
collectAvatarUrl()
collectUserSharePreferences()
collectPrivateContactInfo()
}

fun onEvent(shareEvent: ShareEvent) {
Expand All @@ -40,6 +50,7 @@ internal class ShareViewModel(
)
)
}
savePrivateContactInfo()
}

is ShareEvent.OnPhoneValueChanged -> {
Expand All @@ -50,6 +61,7 @@ internal class ShareViewModel(
)
)
}
savePrivateContactInfo()
}

is ShareEvent.OnAboutAppClicked -> showAboutAppDialog()
Expand All @@ -71,6 +83,17 @@ internal class ShareViewModel(
}
}

private fun savePrivateContactInfo() {
// Cancel any existing job to avoid multiple saves
saveContactInfoJob?.cancel()

// Create a new job with debounce
saveContactInfoJob = viewModelScope.launch {
delay(debounceDelay) // Wait for the debounce period
updatePrivateContactInfo(_uiState.value.privateContactInfo)
}
}

private fun showAboutAppDialog() {
_uiState.update { currentState ->
currentState.copy(isAboutAppDialogVisible = true)
Expand Down Expand Up @@ -130,4 +153,16 @@ internal class ShareViewModel(
}
.launchIn(viewModelScope)
}

private fun collectPrivateContactInfo() {
getPrivateContactInfo()
.onEach { privateContactInfo ->
_uiState.update { currentState ->
currentState.copy(
privateContactInfo = privateContactInfo
)
}
}
.launchIn(viewModelScope)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,13 @@ package com.gravatar.app.homeUi.presentation.home.share

import app.cash.turbine.test
import com.gravatar.app.testUtils.CoroutineTestRule
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
import com.gravatar.app.usercomponent.domain.repository.UserRepository
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
import com.gravatar.restapi.models.Profile
import com.gravatar.restapi.models.ProfileContactInfo
Expand All @@ -14,6 +17,7 @@ import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
Expand Down Expand Up @@ -43,18 +47,34 @@ class ShareViewModelTest {
userSharePreferencesFlow.emit(userSharePreferences)
}
}
private val getPrivateContactInfo: GetPrivateContactInfo = object : GetPrivateContactInfo {
override fun invoke() = privateContactInfoFlow
}
private val updatePrivateContactInfo = object : UpdatePrivateContactInfo {
override suspend fun invoke(privateContactInfo: PrivateContactInfo) {
privateContactInfoFlow.emit(privateContactInfo)
}
}
private val userRepository = mockk<UserRepository>()

private lateinit var viewModel: ShareViewModel

private val avatarUrlFlow: MutableSharedFlow<URL?> = MutableSharedFlow()
private val profileFlow: MutableSharedFlow<Profile?> = MutableSharedFlow()
private val userSharePreferencesFlow: MutableSharedFlow<UserSharePreferences> = MutableSharedFlow()
private val privateContactInfoFlow: MutableSharedFlow<PrivateContactInfo> = MutableSharedFlow()

@Before
fun setup() {
every { userRepository.getProfile() } returns profileFlow
viewModel = ShareViewModel(userRepository, getAvatarUrl, getUserSharePreferences, updateUserSharePreferences)
viewModel = ShareViewModel(
userRepository,
getAvatarUrl,
getUserSharePreferences,
updateUserSharePreferences,
getPrivateContactInfo,
updatePrivateContactInfo
)
}

@Test
Expand Down Expand Up @@ -321,6 +341,25 @@ class ShareViewModelTest {
}
}

@Test
fun `when private contact info is emitted then uiState is updated with private contact info`() = runTest {
// Given
val testPrivateContactInfo = PrivateContactInfo(
privateEmail = "test@example.com",
privatePhone = "123-456-7890"
)

// When
privateContactInfoFlow.emit(testPrivateContactInfo)
advanceUntilIdle()

// Then
viewModel.uiState.test {
val state = awaitItem()
assertEquals(testPrivateContactInfo, state.privateContactInfo)
}
}

@Test
fun `when OnPrivateInformationClicked event is triggered then isPrivateInformationDialogVisible is set to true`() = runTest {
// When
Expand Down Expand Up @@ -352,6 +391,109 @@ class ShareViewModelTest {
}
}

@Test
fun `when OnEmailValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
// Given
val newEmailValue = "test@example.com"

privateContactInfoFlow.test {
// When
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))

// Then
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
privateEmail = newEmailValue
)
assertEquals(expectedPrivateContactInfo, awaitItem())
}
}

@Test
fun `when OnPhoneValueChanged event is triggered then updatePrivateContactInfo is called`() = runTest {
// Given
val newPhoneValue = "123-456-7890"

privateContactInfoFlow.test {
// When
viewModel.onEvent(ShareEvent.OnPhoneValueChanged(newPhoneValue))

// Then
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
privatePhone = newPhoneValue
)
assertEquals(expectedPrivateContactInfo, awaitItem())
}
}

@Test
fun `when email value is changed then updatePrivateContactInfo is not called immediately`() = runTest {
// Given
val newEmailValue = "test@example.com"

privateContactInfoFlow.test {
// When
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))

// Advance time by just under the debounce delay
advanceTimeBy(499) // Just under the 500ms debounce delay

// Then - no emissions yet because of debounce
expectNoEvents()
cancel()
}
}

@Test
fun `when email value is changed then updatePrivateContactInfo is called after debounce delay`() = runTest {
// Given
val newEmailValue = "test@example.com"

privateContactInfoFlow.test {
// When
viewModel.onEvent(ShareEvent.OnEmailValueChanged(newEmailValue))

// Advance time past the debounce delay
advanceTimeBy(501) // Just past the 500ms debounce delay

// Then
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
privateEmail = newEmailValue
)
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())
cancel()
}
}

@Test
fun `when multiple rapid email value changes occur then only the last one triggers updatePrivateContactInfo`() = runTest {
// Given
val firstEmailValue = "first@example.com"
val secondEmailValue = "second@example.com"
val thirdEmailValue = "third@example.com"

privateContactInfoFlow.test {
// When - rapid changes
viewModel.onEvent(ShareEvent.OnEmailValueChanged(firstEmailValue))
advanceTimeBy(100) // Not enough time for debounce

viewModel.onEvent(ShareEvent.OnEmailValueChanged(secondEmailValue))
advanceTimeBy(100) // Not enough time for debounce

viewModel.onEvent(ShareEvent.OnEmailValueChanged(thirdEmailValue))

// Advance time past the debounce delay
advanceTimeBy(501) // Just past the 500ms debounce delay

// Then - only the last value should be emitted
val expectedPrivateContactInfo = PrivateContactInfo.Default.copy(
privateEmail = thirdEmailValue
)
assertEquals(expectedPrivateContactInfo, expectMostRecentItem())

cancel()
}
}

private fun createTestProfile() = Profile {
hash = "test-hash"
displayName = "Test User"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.gravatar.app.foundations.DispatcherProvider
import com.gravatar.app.usercomponent.di.UserPrefs
import com.gravatar.app.usercomponent.domain.model.PrivateContactInfo
import com.gravatar.app.usercomponent.domain.model.UserSharePreferences
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
Expand Down Expand Up @@ -37,10 +38,16 @@ internal interface UserSharePreferencesStorage {
suspend fun saveUserSharePreferences(userSharePreferences: UserSharePreferences)
}

internal interface PrivateContactInfoStorage {
fun getPrivateContactInfo(): Flow<PrivateContactInfo>

suspend fun savePrivateContactInfo(privateContactInfo: PrivateContactInfo)
}

/**
* Convenient interface to clear all user related data in one call.
*/
internal interface UserStorage : AuthTokenStorage, AvatarCacheBusterStorage, UserSharePreferencesStorage {
internal interface UserStorage : AuthTokenStorage, AvatarCacheBusterStorage, UserSharePreferencesStorage, PrivateContactInfoStorage {
suspend fun clear()
}

Expand All @@ -60,6 +67,8 @@ internal class DatastoreUserPrefsStorage(
private const val USER_SHARE_PROFILE_URL_KEY = "share_profile_url"
private const val USER_SHARE_PRIVATE_EMAIL_KEY = "share_private_email"
private const val USER_SHARE_PRIVATE_PHONE_KEY = "share_private_phone"
private const val USER_SHARE_PRIVATE_PHONE_VALUE_KEY = "private_phone_value"
private const val USER_SHARE_PRIVATE_EMAIL_VALUE_KEY = "private_email_value"
}

private val tokenKey = stringPreferencesKey(AUTH_TOKEN_KEY)
Expand All @@ -72,6 +81,8 @@ internal class DatastoreUserPrefsStorage(
private val userShareProfileUrlKey = booleanPreferencesKey(USER_SHARE_PROFILE_URL_KEY)
private val userSharePrivateEmailKey = booleanPreferencesKey(USER_SHARE_PRIVATE_EMAIL_KEY)
private val userSharePrivatePhoneKey = booleanPreferencesKey(USER_SHARE_PRIVATE_PHONE_KEY)
private val userSharePrivatePhoneValueKey = stringPreferencesKey(USER_SHARE_PRIVATE_PHONE_VALUE_KEY)
private val userSharePrivateEmailValueKey = stringPreferencesKey(USER_SHARE_PRIVATE_EMAIL_VALUE_KEY)

override suspend fun getToken(): String? {
return try {
Expand Down Expand Up @@ -151,4 +162,25 @@ internal class DatastoreUserPrefsStorage(
preferences[userShareProfileUrlKey] = userSharePreferences.profileUrl
}
}

override fun getPrivateContactInfo(): Flow<PrivateContactInfo> {
return dataStore.data
.map { preferences ->
PrivateContactInfo(
privateEmail = preferences[userSharePrivateEmailValueKey] ?: "",
privatePhone = preferences[userSharePrivatePhoneValueKey] ?: ""
)
}
.catch { emit(PrivateContactInfo.Default) }
.flowOn(dispatcherProvider.io)
}

override suspend fun savePrivateContactInfo(privateContactInfo: PrivateContactInfo) {
withContext(dispatcherProvider.io) {
dataStore.edit { preferences ->
preferences[userSharePrivateEmailValueKey] = privateContactInfo.privateEmail
preferences[userSharePrivatePhoneValueKey] = privateContactInfo.privatePhone
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import androidx.datastore.preferences.preferencesDataStore
import com.gravatar.app.usercomponent.data.AuthTokenStorage
import com.gravatar.app.usercomponent.data.AvatarCacheBusterStorage
import com.gravatar.app.usercomponent.data.DatastoreUserPrefsStorage
import com.gravatar.app.usercomponent.data.PrivateContactInfoStorage
import com.gravatar.app.usercomponent.data.UserSharePreferencesStorage
import com.gravatar.app.usercomponent.data.UserStorage
import org.koin.android.ext.koin.androidContext
Expand Down Expand Up @@ -42,6 +43,12 @@ internal val datastoreModule = module {
dispatcherProvider = get()
)
}
factory<PrivateContactInfoStorage> {
DatastoreUserPrefsStorage(
dataStore = get(qualifier = named<UserPrefs>()),
dispatcherProvider = get()
)
}
}

private val Context.userPreferencesDataStore by preferencesDataStore(name = "user-preferences")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatar
import com.gravatar.app.usercomponent.domain.usecase.DeleteUserAvatarUseCase
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrl
import com.gravatar.app.usercomponent.domain.usecase.GetAvatarUrlUseCase
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.GetPrivateContactInfoUseCase
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferences
import com.gravatar.app.usercomponent.domain.usecase.GetUserSharePreferencesUseCase
import com.gravatar.app.usercomponent.domain.usecase.IsUserLoggedIn
Expand All @@ -23,6 +25,8 @@ import com.gravatar.app.usercomponent.domain.usecase.Logout
import com.gravatar.app.usercomponent.domain.usecase.LogoutUseCase
import com.gravatar.app.usercomponent.domain.usecase.SelectAvatarUseCase
import com.gravatar.app.usercomponent.domain.usecase.SelectUserAvatar
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfo
import com.gravatar.app.usercomponent.domain.usecase.UpdatePrivateContactInfoUseCase
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferences
import com.gravatar.app.usercomponent.domain.usecase.UpdateUserSharePreferencesUseCase
import com.gravatar.app.usercomponent.domain.usecase.UploadAvatarUseCase
Expand All @@ -45,6 +49,8 @@ val userComponentModule = module {
factoryOf(::UploadAvatarUseCase) { bind<UploadUserAvatar>() }
factoryOf(::GetUserSharePreferencesUseCase) { bind<GetUserSharePreferences>() }
factoryOf(::UpdateUserSharePreferencesUseCase) { bind<UpdateUserSharePreferences>() }
factoryOf(::GetPrivateContactInfoUseCase) { bind<GetPrivateContactInfo>() }
factoryOf(::UpdatePrivateContactInfoUseCase) { bind<UpdatePrivateContactInfo>() }
factoryOf(::WordPressClient)
singleOf(::InMemoryUserSessionPersistence) { bind<UserSessionPersistence>() }
includes(httpClientModule)
Expand Down
Loading