diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt index 4875defa89..538c7ddeaa 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenFragment.kt @@ -21,10 +21,12 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.core.view.GravityCompat import androidx.core.view.WindowInsetsCompat import androidx.drawerlayout.widget.DrawerLayout +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope import androidx.navigation.fragment.findNavController import com.google.android.material.imageview.ShapeableImageView @@ -37,12 +39,10 @@ import org.groundplatform.android.R import org.groundplatform.android.data.local.room.converter.SubmissionDeltasConverter import org.groundplatform.android.databinding.HomeScreenFragBinding import org.groundplatform.android.databinding.NavDrawerHeaderBinding -import org.groundplatform.android.model.User import org.groundplatform.android.repository.UserRepository import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.ui.common.BackPressListener import org.groundplatform.android.ui.common.EphemeralPopups -import org.groundplatform.android.ui.components.ConfirmationDialog import org.groundplatform.android.ui.main.MainViewModel import org.groundplatform.android.util.setComposableContent import org.groundplatform.android.util.systemInsets @@ -60,7 +60,6 @@ class HomeScreenFragment : @Inject lateinit var userRepository: UserRepository private lateinit var binding: HomeScreenFragBinding private lateinit var homeScreenViewModel: HomeScreenViewModel - private lateinit var user: User override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -96,9 +95,9 @@ class HomeScreenFragment : HomeScreenFragmentDirections.actionHomeScreenFragmentToSurveySelectorFragment(false) ) } - viewLifecycleOwner.lifecycleScope.launch { user = userRepository.getAuthenticatedUser() } + navHeader.findViewById(R.id.user_image).setOnClickListener { - showSignOutConfirmationDialogs() + homeScreenViewModel.showUserDetails() } updateNavHeader() // Re-open data collection screen if draft submission is present. @@ -131,6 +130,8 @@ class HomeScreenFragment : val navigationView = view.findViewById(R.id.nav_view) val menuItem = navigationView.menu.findItem(R.id.nav_log_version) menuItem.title = String.format(getString(R.string.build), BuildConfig.VERSION_NAME) + + binding.composeView.setComposableContent { SetupUserConfirmationDialog() } } private fun updateNavHeader() = @@ -192,43 +193,17 @@ class HomeScreenFragment : return true } - private fun showSignOutConfirmationDialogs() { - val showUserDetailsDialog = mutableStateOf(false) - val showSignOutDialog = mutableStateOf(false) - - fun showUserDetailsDialog() { - showUserDetailsDialog.value = true - showSignOutDialog.value = false - } - - fun showSignOutDialog() { - showUserDetailsDialog.value = false - showSignOutDialog.value = true - } - - fun hideAllDialogs() { - showUserDetailsDialog.value = false - showSignOutDialog.value = false - } - - // Init state for composition - showUserDetailsDialog() - - // Note: Adding a compose view to the fragment's view dynamically causes the navigation click to - // stop working after 1st time. Revisit this once the navigation drawer is also generated using - // compose. - binding.composeView.setComposableContent { - if (showUserDetailsDialog.value) { - UserDetailsDialog(user, { showSignOutDialog() }, { hideAllDialogs() }) - } - if (showSignOutDialog.value) { - ConfirmationDialog( - title = R.string.sign_out_dialog_title, - description = R.string.sign_out_dialog_body, - confirmButtonText = R.string.sign_out, - onConfirmClicked = { homeScreenViewModel.signOut() }, - ) - } - } + @Composable + private fun SetupUserConfirmationDialog() { + val state by homeScreenViewModel.accountDialogState.collectAsStateWithLifecycle() + val user by homeScreenViewModel.user.collectAsStateWithLifecycle(null) + + UserAccountDialogs( + state = state, + user = user, + onSignOut = { homeScreenViewModel.signOut() }, + onShowSignOutConfirmation = { homeScreenViewModel.showSignOutConfirmation() }, + onDismiss = { homeScreenViewModel.dismissLogoutDialog() }, + ) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt index 72d82335b3..472b576cf9 100644 --- a/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/home/HomeScreenViewModel.kt @@ -20,19 +20,27 @@ import androidx.lifecycle.MutableLiveData import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.groundplatform.android.data.sync.MediaUploadWorkManager import org.groundplatform.android.data.sync.MutationSyncWorkManager +import org.groundplatform.android.model.User import org.groundplatform.android.model.submission.DraftSubmission import org.groundplatform.android.repository.MutationRepository import org.groundplatform.android.repository.OfflineAreaRepository import org.groundplatform.android.repository.SubmissionRepository import org.groundplatform.android.repository.SurveyRepository import org.groundplatform.android.repository.UserRepository +import org.groundplatform.android.system.auth.SignInState import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.ui.common.SharedViewModel import timber.log.Timber @@ -56,6 +64,15 @@ internal constructor( private val _openDrawerRequests: MutableSharedFlow = MutableSharedFlow() val openDrawerRequestsFlow: SharedFlow = _openDrawerRequests.asSharedFlow() + private val _accountDialogState = MutableStateFlow(AccountDialogState.HIDDEN) + val accountDialogState: StateFlow = _accountDialogState.asStateFlow() + + val user: Flow = + userRepository + .getSignInState() + .filter { it is SignInState.SignedIn } + .map { (it as SignInState.SignedIn).user } + // TODO: Allow tile source configuration from a non-survey accessible source. // Issue URL: https://github.com/google/ground-android/issues/1730 val showOfflineAreaMenuItem: LiveData = MutableLiveData(true) @@ -120,6 +137,29 @@ internal constructor( suspend fun getOfflineAreas() = offlineAreaRepository.offlineAreas().first() fun signOut() { + _accountDialogState.value = AccountDialogState.HIDDEN viewModelScope.launch { userRepository.signOut() } } + + fun showUserDetails() { + _accountDialogState.value = AccountDialogState.USER_DETAILS + } + + fun showSignOutConfirmation() { + _accountDialogState.value = AccountDialogState.SIGN_OUT_CONFIRMATION + } + + fun dismissLogoutDialog() { + _accountDialogState.value = AccountDialogState.HIDDEN + } + + /** + * Represents the possible visibility states of dialogs related to the user's account, such as + * profile details and sign-out confirmation. + */ + enum class AccountDialogState { + HIDDEN, + USER_DETAILS, + SIGN_OUT_CONFIRMATION, + } } diff --git a/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt b/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt new file mode 100644 index 0000000000..f466eb399e --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/home/UserAccountDialogs.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.home + +import androidx.compose.runtime.Composable +import org.groundplatform.android.R +import org.groundplatform.android.model.User +import org.groundplatform.android.ui.components.ConfirmationDialog + +@Composable +fun UserAccountDialogs( + state: HomeScreenViewModel.AccountDialogState, + user: User?, + onSignOut: () -> Unit, + onShowSignOutConfirmation: () -> Unit, + onDismiss: () -> Unit, +) { + when (state) { + HomeScreenViewModel.AccountDialogState.USER_DETAILS -> + user?.let { + UserDetailsDialog( + user = it, + signOutCallback = onShowSignOutConfirmation, + dismissCallback = onDismiss, + ) + } + HomeScreenViewModel.AccountDialogState.SIGN_OUT_CONFIRMATION -> + ConfirmationDialog( + title = R.string.sign_out_dialog_title, + description = R.string.sign_out_dialog_body, + confirmButtonText = R.string.sign_out, + onConfirmClicked = onSignOut, + onDismiss = onDismiss, + ) + else -> {} + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt new file mode 100644 index 0000000000..765d84db7c --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/home/HomeScreenViewModelTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.groundplatform.android.ui.home + +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.advanceUntilIdle +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData +import org.groundplatform.android.system.auth.FakeAuthenticationManager +import org.groundplatform.android.system.auth.SignInState +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.HIDDEN +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.SIGN_OUT_CONFIRMATION +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.USER_DETAILS +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class HomeScreenViewModelTest : BaseHiltTest() { + + @Inject lateinit var authenticationManager: FakeAuthenticationManager + @Inject lateinit var viewModel: HomeScreenViewModel + + @Before + override fun setUp() { + super.setUp() + authenticationManager.setUser(FakeData.USER) + authenticationManager.signIn() + } + + @Test + fun testShowUserDetails() { + viewModel.showUserDetails() + + assertThat(viewModel.accountDialogState.value).isEqualTo(USER_DETAILS) + } + + @Test + fun testShowSignOutConfirmation() { + viewModel.showSignOutConfirmation() + + assertThat(viewModel.accountDialogState.value).isEqualTo(SIGN_OUT_CONFIRMATION) + } + + @Test + fun testDismissLogoutDialog() { + viewModel.showUserDetails() + viewModel.dismissLogoutDialog() + + assertThat(viewModel.accountDialogState.value).isEqualTo(HIDDEN) + } + + @Test + fun testSignOut() = runWithTestDispatcher { + viewModel.showSignOutConfirmation() + viewModel.signOut() + + advanceUntilIdle() + + assertThat(viewModel.accountDialogState.value).isEqualTo(HIDDEN) + assertThat(authenticationManager.signInState.filterIsInstance().first()) + .isEqualTo(SignInState.SignedOut) + } + + @Test + fun testAuthenticatedUser() = runWithTestDispatcher { + advanceUntilIdle() + + val user = viewModel.user.filterNotNull().first() + assertThat(user).isEqualTo(FakeData.USER) + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt b/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt new file mode 100644 index 0000000000..67c1b02b70 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/home/UserAccountDialogsTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.groundplatform.android.ui.home + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.espresso.matcher.ViewMatchers.assertThat +import org.groundplatform.android.FakeData +import org.groundplatform.android.R +import org.groundplatform.android.getString +import org.groundplatform.android.model.User +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState +import org.groundplatform.android.ui.home.HomeScreenViewModel.AccountDialogState.USER_DETAILS +import org.hamcrest.Matchers.`is` +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class UserAccountDialogsTest { + + @get:Rule val composeTestRule = createComposeRule() + + private lateinit var user: User + + @Before + fun setUp() { + user = FakeData.USER + } + + private fun setupContent( + state: AccountDialogState, + onSignOut: () -> Unit = {}, + onShowSignOutConfirmation: () -> Unit = {}, + ) { + composeTestRule.setContent { + UserAccountDialogs( + state = state, + user = user, + onSignOut = onSignOut, + onShowSignOutConfirmation = onShowSignOutConfirmation, + onDismiss = {}, + ) + } + } + + @Test + fun showUserDetailsDialog() { + setupContent(state = USER_DETAILS) + + composeTestRule.onNodeWithText(user.displayName).assertExists() + composeTestRule.onNodeWithText(user.email).assertExists() + } + + @Test + fun showSignOutConfirmationDialog() { + setupContent(state = AccountDialogState.SIGN_OUT_CONFIRMATION) + + composeTestRule.onNodeWithText(getString(R.string.sign_out_dialog_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(getString(R.string.sign_out_dialog_body)).assertIsDisplayed() + } + + @Test + fun clickSignOut_invokesCallback() { + var signOutClicked = false + setupContent( + state = AccountDialogState.SIGN_OUT_CONFIRMATION, + onSignOut = { signOutClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.sign_out)).performClick() + + assertThat(signOutClicked, `is`(true)) + } + + @Test + fun clickSignOutInUserDetails_invokesShowSignOutConfirmation() { + var showSignOutConfirmationClicked = false + setupContent( + state = USER_DETAILS, + onShowSignOutConfirmation = { showSignOutConfirmationClicked = true }, + ) + + composeTestRule.onNodeWithText(getString(R.string.sign_out)).performClick() + + assertThat(showSignOutConfirmationClicked, `is`(true)) + } +}