Skip to content
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 @@ -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()}",
Expand All @@ -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)
}
}
Expand Down Expand Up @@ -156,5 +159,5 @@ private fun PreviewSyncListItem(
description = "Lacuna Fund Cocoa Mapping",
)
) {
AppTheme { SyncListItem(Modifier, detail) }
AppTheme { SyncListItem(detail) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,15 @@
*/
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

/**
* 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand All @@ -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()
}
},
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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.background,
) { 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 = {},
)
}
}
Original file line number Diff line number Diff line change
@@ -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<SyncStatusDetail> = emptyList())
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<List<SyncStatusDetail>> =
/** The current UI state representing local changes being synced to remote servers. */
val uiState: StateFlow<SyncStatusState> =
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 =
Expand Down
30 changes: 0 additions & 30 deletions app/src/main/res/drawable-anydpi/ic_arrow_back.xml

This file was deleted.

53 changes: 0 additions & 53 deletions app/src/main/res/layout/sync_status_frag.xml

This file was deleted.

Loading
Loading