From 3f8d5b036e2535702d4ff1516a056273867e6c18 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Tue, 8 Sep 2026 17:21:39 +0530 Subject: [PATCH 1/2] refactor: migrate SyncStatusFragment to Jetpack Compose - Replace sync_status_frag.xml with pure Compose SyncStatusScreen - Convert SyncStatusViewModel from LiveData to StateFlow - Annotate SyncStatusDetail and SyncStatusState with @Immutable - Update SyncListItem parameters and modifiers for Compose idioms - Remove obsolete XML layout and unused ic_arrow_back.xml drawable - Update SyncStatusFragment to use ComposeView with DisposeOnViewTreeLifecycleDestroyed - Add unit and Compose UI tests for SyncStatusViewModel, SyncStatusScreen, and update SyncStatusFragmentTest --- .../android/ui/syncstatus/SyncListItem.kt | 15 +- .../android/ui/syncstatus/SyncStatusDetail.kt | 2 + .../ui/syncstatus/SyncStatusFragment.kt | 45 +++--- .../android/ui/syncstatus/SyncStatusScreen.kt | 133 ++++++++++++++++++ .../android/ui/syncstatus/SyncStatusState.kt | 22 +++ .../ui/syncstatus/SyncStatusViewModel.kt | 23 +-- .../res/drawable-anydpi/ic_arrow_back.xml | 30 ---- app/src/main/res/layout/sync_status_frag.xml | 53 ------- app/src/main/res/navigation/nav_graph.xml | 3 +- .../ui/syncstatus/SyncStatusFragmentTest.kt | 71 ++-------- .../ui/syncstatus/SyncStatusScreenTest.kt | 115 +++++++++++++++ .../ui/syncstatus/SyncStatusViewModelTest.kt | 121 ++++++++++++++++ 12 files changed, 449 insertions(+), 184 deletions(-) create mode 100644 app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt create mode 100644 app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusState.kt delete mode 100644 app/src/main/res/drawable-anydpi/ic_arrow_back.xml delete mode 100644 app/src/main/res/layout/sync_status_frag.xml create mode 100644 app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreenTest.kt create mode 100644 app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModelTest.kt diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncListItem.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncListItem.kt index e24e73e171..3d4e15275d 100644 --- a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncListItem.kt +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncListItem.kt @@ -46,10 +46,13 @@ import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.ui.theme.AppTheme @Composable -fun SyncListItem(modifier: Modifier, detail: SyncStatusDetail) { - Column { - Row(modifier.fillMaxWidth().padding(top = 8.dp, end = 24.dp, bottom = 8.dp, start = 16.dp)) { - Column(modifier.weight(1f)) { +fun SyncListItem( + detail: SyncStatusDetail, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Row(Modifier.fillMaxWidth().padding(top = 8.dp, end = 24.dp, bottom = 8.dp, start = 16.dp)) { + Column(modifier = Modifier.weight(1f)) { val date = detail.timestamp Text( text = "${date.toFormattedDate()} • ${date.toFormattedTime()}", @@ -72,7 +75,7 @@ fun SyncListItem(modifier: Modifier, detail: SyncStatusDetail) { style = MaterialTheme.typography.bodySmall, ) } - Column(modifier = modifier.padding(start = 16.dp).align(alignment = CenterVertically)) { + Column(modifier = Modifier.padding(start = 16.dp).align(alignment = CenterVertically)) { StatusIcon(status = detail.status, modifier = Modifier) } } @@ -156,5 +159,5 @@ private fun PreviewSyncListItem( description = "Lacuna Fund Cocoa Mapping", ) ) { - AppTheme { SyncListItem(Modifier, detail) } + AppTheme { SyncListItem(detail) } } diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusDetail.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusDetail.kt index 835e121317..4afbb69dab 100644 --- a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusDetail.kt +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusDetail.kt @@ -15,6 +15,7 @@ */ package org.groundplatform.android.ui.syncstatus +import androidx.compose.runtime.Immutable import org.groundplatform.domain.model.mutation.Mutation import org.groundplatform.domain.model.submission.UploadQueueEntry @@ -22,6 +23,7 @@ import org.groundplatform.domain.model.submission.UploadQueueEntry * Defines the set of data needed to display the human-readable status of a queued * [UploadQueueEntry]. */ +@Immutable data class SyncStatusDetail( /** The username of the user who made this change. */ val user: String, diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragment.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragment.kt index 97b40eb407..76cab955b7 100644 --- a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragment.kt +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragment.kt @@ -19,18 +19,11 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.livedata.observeAsState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.navigation.fragment.findNavController import dagger.hilt.android.AndroidEntryPoint -import org.groundplatform.android.databinding.SyncStatusFragBinding +import org.groundplatform.android.R import org.groundplatform.android.ui.common.AbstractFragment import org.groundplatform.android.util.setComposableContent @@ -41,7 +34,7 @@ import org.groundplatform.android.util.setComposableContent @AndroidEntryPoint class SyncStatusFragment : AbstractFragment() { - lateinit var viewModel: SyncStatusViewModel + private lateinit var viewModel: SyncStatusViewModel override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -54,22 +47,18 @@ class SyncStatusFragment : AbstractFragment() { savedInstanceState: Bundle?, ): View { super.onCreateView(inflater, container, savedInstanceState) - val binding = SyncStatusFragBinding.inflate(inflater, container, false) - binding.viewModel = viewModel - binding.lifecycleOwner = this - binding.composeView.setComposableContent { ShowSyncItems() } - getAbstractActivity().setSupportActionBar(binding.syncStatusToolbar) - return binding.root - } - - @Composable - private fun ShowSyncItems() { - val list by viewModel.uploadStatus.observeAsState() - list?.let { - LazyColumn(Modifier.fillMaxSize().testTag("sync list")) { - items(it) { - SyncListItem(modifier = Modifier.semantics { testTag = "item ${it.user}" }, detail = it) - } + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setComposableContent { + SyncStatusScreen( + viewModel = viewModel, + onNavigateUp = { + val navController = findNavController() + if (navController.currentDestination?.id == R.id.sync_status_fragment) { + navController.navigateUp() + } + }, + ) } } } diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt new file mode 100644 index 0000000000..b7c18a86c5 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt @@ -0,0 +1,133 @@ +/* + * 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.syncstatus + +import androidx.annotation.VisibleForTesting +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.groundplatform.android.R +import org.groundplatform.android.ui.common.ExcludeFromJacocoGeneratedReport +import org.groundplatform.android.ui.components.Toolbar +import org.groundplatform.domain.model.mutation.Mutation +import org.groundplatform.ui.theme.AppTheme + +const val SYNC_STATUS_LIST_TEST_TAG = "sync list" + +/** + * Stateful entry point for the Sync Status screen. + * + * @param viewModel The ViewModel providing UI state. + * @param onNavigateUp Callback when the back navigation icon is clicked. + */ +@Composable +fun SyncStatusScreen( + viewModel: SyncStatusViewModel, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + + SyncStatusScreen(uiState = uiState, onNavigateUp = onNavigateUp, modifier = modifier) +} + +/** + * Stateless composable for the Sync Status screen. + * + * @param uiState Current UI state of the sync status screen. + * @param onNavigateUp Callback when the back navigation icon is clicked. + * @param modifier Modifier for the root container. + */ +@VisibleForTesting +@Composable +fun SyncStatusScreen( + uiState: SyncStatusState, + onNavigateUp: () -> Unit, + modifier: Modifier = Modifier, +) { + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + Toolbar( + stringRes = R.string.data_sync_status, + showNavigationIcon = true, + titleCentered = true, + iconClick = onNavigateUp, + ) + }, + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ) { innerPadding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(innerPadding).testTag(SYNC_STATUS_LIST_TEST_TAG) + ) { + items(items = uiState.items) { item -> + SyncListItem(detail = item, modifier = Modifier.semantics { testTag = "item ${item.user}" }) + } + } + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun SyncStatusScreenEmptyPreview() { + AppTheme { SyncStatusScreen(uiState = SyncStatusState(), onNavigateUp = {}) } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +private fun SyncStatusScreenLoadedPreview() { + AppTheme { + SyncStatusScreen( + uiState = + SyncStatusState( + items = + listOf( + SyncStatusDetail( + user = "Jane Doe", + status = Mutation.SyncStatus.PENDING, + timestamp = 1700000000000L, + label = "Map the farms", + subtitle = "IDX21311", + description = "Lacuna Fund Cocoa Mapping", + ), + SyncStatusDetail( + user = "John Smith", + status = Mutation.SyncStatus.IN_PROGRESS, + timestamp = 1700000100000L, + label = "Forest Survey", + subtitle = "Site A", + description = "Tree canopy density", + ), + ) + ), + onNavigateUp = {}, + ) + } +} diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusState.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusState.kt new file mode 100644 index 0000000000..96532e5613 --- /dev/null +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusState.kt @@ -0,0 +1,22 @@ +/* + * 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.syncstatus + +import androidx.compose.runtime.Immutable + +/** Represents the UI state for the Sync Status screen. */ +@Immutable data class SyncStatusState(val items: List = emptyList()) diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModel.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModel.kt index fd3f730973..6f9946770a 100644 --- a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModel.kt +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModel.kt @@ -15,10 +15,12 @@ */ package org.groundplatform.android.ui.syncstatus -import androidx.lifecycle.LiveData -import androidx.lifecycle.asLiveData +import androidx.lifecycle.viewModelScope import javax.inject.Inject +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import org.groundplatform.android.ui.common.AbstractViewModel import org.groundplatform.android.ui.common.LocationOfInterestHelper import org.groundplatform.domain.model.mutation.LocationOfInterestMutation @@ -47,15 +49,18 @@ internal constructor( private val surveyRepository: SurveyRepositoryInterface, ) : AbstractViewModel() { - /** - * A complete list of [SyncStatusDetail] indicating the current status of local changes being - * synced to remote servers. - */ - internal val uploadStatus: LiveData> = + /** The current UI state representing local changes being synced to remote servers. */ + val uiState: StateFlow = mutationRepository .getUploadQueueFlow() - .map { it.mapNotNull { upload -> toSyncStatusDetail(upload) } } - .asLiveData() + .map { queue -> + SyncStatusState(items = queue.mapNotNull { upload -> toSyncStatusDetail(upload) }) + } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = SyncStatusState(), + ) private suspend fun toSyncStatusDetail(uploadQueueEntry: UploadQueueEntry): SyncStatusDetail? { val mutation = diff --git a/app/src/main/res/drawable-anydpi/ic_arrow_back.xml b/app/src/main/res/drawable-anydpi/ic_arrow_back.xml deleted file mode 100644 index e3a1992f8f..0000000000 --- a/app/src/main/res/drawable-anydpi/ic_arrow_back.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - diff --git a/app/src/main/res/layout/sync_status_frag.xml b/app/src/main/res/layout/sync_status_frag.xml deleted file mode 100644 index a4d7f90efc..0000000000 --- a/app/src/main/res/layout/sync_status_frag.xml +++ /dev/null @@ -1,53 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/navigation/nav_graph.xml b/app/src/main/res/navigation/nav_graph.xml index b3c76adc39..7012ac92ff 100644 --- a/app/src/main/res/navigation/nav_graph.xml +++ b/app/src/main/res/navigation/nav_graph.xml @@ -82,8 +82,7 @@ + android:label="@string/sync_status" /> diff --git a/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragmentTest.kt b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragmentTest.kt index 9a92f07417..7c3be86879 100644 --- a/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragmentTest.kt +++ b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusFragmentTest.kt @@ -16,31 +16,23 @@ package org.groundplatform.android.ui.syncstatus import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.onNodeWithText -import androidx.test.espresso.Espresso.onView -import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.isDisplayed -import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.compose.ui.test.performClick +import androidx.navigation.NavController +import com.google.common.truth.Truth.assertThat import dagger.hilt.android.testing.HiltAndroidTest import javax.inject.Inject import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import org.groundplatform.android.BaseHiltTest import org.groundplatform.android.FakeData.SURVEY -import org.groundplatform.android.FakeData.USER import org.groundplatform.android.R -import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore -import org.groundplatform.android.data.local.stores.LocalSubmissionStore import org.groundplatform.android.data.local.stores.LocalSurveyStore -import org.groundplatform.android.data.local.stores.LocalUserStore import org.groundplatform.android.data.remote.FakeRemoteDataStore import org.groundplatform.android.testrules.FragmentScenarioRule -import org.groundplatform.domain.model.geometry.Coordinates -import org.groundplatform.domain.model.geometry.Point import org.groundplatform.domain.repository.SurveyRepositoryInterface -import org.groundplatform.testing.FakeDataGenerator import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -54,73 +46,40 @@ class SyncStatusFragmentTest : BaseHiltTest() { @get:Rule val fragmentScenario = FragmentScenarioRule() @Inject lateinit var fakeRemoteDataStore: FakeRemoteDataStore - @Inject lateinit var localLoiStore: LocalLocationOfInterestStore - @Inject lateinit var localSubmissionStore: LocalSubmissionStore @Inject lateinit var localSurveyStore: LocalSurveyStore - @Inject lateinit var localUserStore: LocalUserStore @Inject lateinit var surveyRepository: SurveyRepositoryInterface - @Test - fun `Toolbar should be displayed`() { - setupFragment() - - onView(withId(R.id.sync_status_toolbar)).check(matches(isDisplayed())) - } - @Test fun `Sync items should be displayed`() = runWithTestDispatcher { setupSurvey() setupFragment() + advanceUntilIdle() composeTestRule.onNodeWithTag("sync list").assertIsDisplayed() } @Test - fun `Entry for LOI Mutation is displayed`() = runWithTestDispatcher { - setupSurvey() - - // Insert a new LOI mutation in local db - localUserStore.insertOrUpdateUser(USER) - localLoiStore.applyAndEnqueue( - FakeDataGenerator.newLoiMutation(geometry = Point(Coordinates(0.0, 0.0))) + fun `Clicking back button in toolbar navigates up`() = runWithTestDispatcher { + var navController: NavController? = null + fragmentScenario.launchFragmentWithNavController( + destId = R.id.sync_status_fragment, + navControllerCallback = { navController = it }, ) advanceUntilIdle() - setupFragment() - - composeTestRule.onNodeWithTag("sync list").assertIsDisplayed() - composeTestRule.onNodeWithText("Job • Test LOI Name").assertIsDisplayed() - composeTestRule.onNodeWithText("Pending").assertIsDisplayed() // Status - composeTestRule.onNodeWithText("Survey title").assertIsDisplayed() - composeTestRule.onNodeWithText("Test survey description").assertDoesNotExist() - } - - @Test - fun `Entry for Submission Mutation is displayed`() = runWithTestDispatcher { - setupSurvey() - - // Insert a new submission mutation in local db - localUserStore.insertOrUpdateUser(USER) - localLoiStore.apply(FakeDataGenerator.newLoiMutation(geometry = Point(Coordinates(0.0, 0.0)))) - localSubmissionStore.applyAndEnqueue(FakeDataGenerator.newSubmissionMutation()) + composeTestRule.onNodeWithContentDescription("Back").performClick() advanceUntilIdle() - setupFragment() - - composeTestRule.onNodeWithTag("sync list").assertIsDisplayed() - composeTestRule.onNodeWithText("Job • Survey title").assertIsDisplayed() - composeTestRule.onNodeWithText("Pending").assertIsDisplayed() // Status + assertThat(navController?.currentDestination?.id).isNotEqualTo(R.id.sync_status_fragment) } - private fun setupSurvey() = runWithTestDispatcher { + private suspend fun setupSurvey() { fakeRemoteDataStore.surveys = listOf(SURVEY) localSurveyStore.insertOrUpdateSurvey(SURVEY) surveyRepository.activateSurvey(SURVEY.id) - advanceUntilIdle() } - private fun setupFragment() = runWithTestDispatcher { + private fun setupFragment() { fragmentScenario.launchFragmentInHiltContainer() - advanceUntilIdle() } } diff --git a/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreenTest.kt b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreenTest.kt new file mode 100644 index 0000000000..c403cde9d9 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreenTest.kt @@ -0,0 +1,115 @@ +/* + * 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.syncstatus + +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.google.common.truth.Truth.assertThat +import org.groundplatform.domain.model.mutation.Mutation +import org.groundplatform.ui.theme.AppTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SyncStatusScreenTest { + + @get:Rule val composeTestRule = createComposeRule() + + @Test + fun `Toolbar title is displayed`() { + composeTestRule.setContent { + AppTheme { SyncStatusScreen(uiState = SyncStatusState(), onNavigateUp = {}) } + } + + composeTestRule.onNodeWithText("Data sync status").assertIsDisplayed() + } + + @Test + fun `Navigate up click triggers callback`() { + var navigatedUp = false + composeTestRule.setContent { + AppTheme { + SyncStatusScreen( + uiState = SyncStatusState(), + onNavigateUp = { navigatedUp = true }, + ) + } + } + + composeTestRule.onNodeWithContentDescription("Back").performClick() + assertThat(navigatedUp).isTrue() + } + + @Test + fun `Empty state displays list container`() { + composeTestRule.setContent { + AppTheme { SyncStatusScreen(uiState = SyncStatusState(), onNavigateUp = {}) } + } + + composeTestRule.onNodeWithTag(SYNC_STATUS_LIST_TEST_TAG).assertIsDisplayed() + } + + @Test + fun `Loaded state displays list and items`() { + val detail = + SyncStatusDetail( + user = "Jane Doe", + status = Mutation.SyncStatus.PENDING, + timestamp = 1700000000000L, + label = "Map the farms", + subtitle = "IDX21311", + description = "Lacuna Fund Cocoa Mapping", + ) + + composeTestRule.setContent { + AppTheme { + SyncStatusScreen( + uiState = SyncStatusState(items = listOf(detail)), + onNavigateUp = {}, + ) + } + } + + composeTestRule.onNodeWithTag(SYNC_STATUS_LIST_TEST_TAG).assertIsDisplayed() + composeTestRule.onNodeWithText("Map the farms • IDX21311").assertIsDisplayed() + composeTestRule.onNodeWithText("Lacuna Fund Cocoa Mapping").assertIsDisplayed() + composeTestRule.onNodeWithText("Pending").assertIsDisplayed() + } + + @Test + fun `Custom modifier is applied to root`() { + composeTestRule.setContent { + AppTheme { + SyncStatusScreen( + uiState = SyncStatusState(), + onNavigateUp = {}, + modifier = Modifier.testTag("root_test_tag"), + ) + } + } + + composeTestRule.onNodeWithTag("root_test_tag").assertIsDisplayed() + } +} diff --git a/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModelTest.kt b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModelTest.kt new file mode 100644 index 0000000000..01480f5ed2 --- /dev/null +++ b/app/src/test/java/org/groundplatform/android/ui/syncstatus/SyncStatusViewModelTest.kt @@ -0,0 +1,121 @@ +/* + * 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.syncstatus + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import dagger.hilt.android.testing.HiltAndroidTest +import javax.inject.Inject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import org.groundplatform.android.BaseHiltTest +import org.groundplatform.android.FakeData.SURVEY +import org.groundplatform.android.FakeData.USER +import org.groundplatform.android.data.local.stores.LocalLocationOfInterestStore +import org.groundplatform.android.data.local.stores.LocalSubmissionStore +import org.groundplatform.android.data.local.stores.LocalSurveyStore +import org.groundplatform.android.data.local.stores.LocalUserStore +import org.groundplatform.android.data.remote.FakeRemoteDataStore +import org.groundplatform.domain.model.geometry.Coordinates +import org.groundplatform.domain.model.geometry.Point +import org.groundplatform.domain.model.mutation.Mutation +import org.groundplatform.domain.repository.SurveyRepositoryInterface +import org.groundplatform.testing.FakeDataGenerator +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltAndroidTest +@RunWith(RobolectricTestRunner::class) +class SyncStatusViewModelTest : BaseHiltTest() { + + @Inject lateinit var fakeRemoteDataStore: FakeRemoteDataStore + @Inject lateinit var localLoiStore: LocalLocationOfInterestStore + @Inject lateinit var localSubmissionStore: LocalSubmissionStore + @Inject lateinit var localSurveyStore: LocalSurveyStore + @Inject lateinit var localUserStore: LocalUserStore + @Inject lateinit var surveyRepository: SurveyRepositoryInterface + @Inject lateinit var syncStatusViewModel: SyncStatusViewModel + + @Test + fun `uiState initial value is empty`() { + val initial = syncStatusViewModel.uiState.value + assertThat(initial.items).isEmpty() + } + + @Test + fun `uiState emits empty state when no mutations in queue`() = runWithTestDispatcher { + advanceUntilIdle() + + syncStatusViewModel.uiState.test { + val item = awaitItem() + assertThat(item.items).isEmpty() + } + } + + @Test + fun `uiState emits LOI mutation mapped to SyncStatusDetail`() = runWithTestDispatcher { + setupSurvey() + + localUserStore.insertOrUpdateUser(USER) + localLoiStore.applyAndEnqueue( + FakeDataGenerator.newLoiMutation(geometry = Point(Coordinates(0.0, 0.0))) + ) + advanceUntilIdle() + + syncStatusViewModel.uiState.test { + assertThat(awaitItem().items).isEmpty() + + val item = awaitItem() + assertThat(item.items).hasSize(1) + assertThat(item.items[0].user).isEqualTo(USER.displayName) + assertThat(item.items[0].label).isEqualTo("Job") + assertThat(item.items[0].subtitle).isEqualTo("Test LOI Name") + assertThat(item.items[0].description).isEqualTo("Survey title") + assertThat(item.items[0].status).isEqualTo(Mutation.SyncStatus.PENDING) + } + } + + @Test + fun `uiState emits Submission mutation mapped to SyncStatusDetail`() = runWithTestDispatcher { + setupSurvey() + + localUserStore.insertOrUpdateUser(USER) + localLoiStore.apply(FakeDataGenerator.newLoiMutation(geometry = Point(Coordinates(0.0, 0.0)))) + localSubmissionStore.applyAndEnqueue(FakeDataGenerator.newSubmissionMutation()) + advanceUntilIdle() + + syncStatusViewModel.uiState.test { + assertThat(awaitItem().items).isEmpty() + + val item = awaitItem() + assertThat(item.items).hasSize(1) + assertThat(item.items[0].user).isEqualTo(USER.displayName) + assertThat(item.items[0].label).isEqualTo("Job") + assertThat(item.items[0].subtitle).isEqualTo("Survey title") + assertThat(item.items[0].description).isEqualTo("Test survey description") + assertThat(item.items[0].status).isEqualTo(Mutation.SyncStatus.PENDING) + } + } + + private suspend fun setupSurvey() { + fakeRemoteDataStore.surveys = listOf(SURVEY) + localSurveyStore.insertOrUpdateSurvey(SURVEY) + surveyRepository.activateSurvey(SURVEY.id) + } +} From e2f0f2e78e439a28e98a910870594d73074b02f6 Mon Sep 17 00:00:00 2001 From: Shobhit Agarwal Date: Wed, 9 Sep 2026 10:36:29 +0530 Subject: [PATCH 2/2] Fix container color --- .../groundplatform/android/ui/syncstatus/SyncStatusScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt index b7c18a86c5..1f1e2dbaef 100644 --- a/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt +++ b/app/src/main/java/org/groundplatform/android/ui/syncstatus/SyncStatusScreen.kt @@ -80,7 +80,7 @@ fun SyncStatusScreen( iconClick = onNavigateUp, ) }, - containerColor = MaterialTheme.colorScheme.surfaceContainer, + containerColor = MaterialTheme.colorScheme.background, ) { innerPadding -> LazyColumn( modifier = Modifier.fillMaxSize().padding(innerPadding).testTag(SYNC_STATUS_LIST_TEST_TAG)