diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt index 038fcaab..617f75d7 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApp.kt @@ -24,13 +24,14 @@ import androidx.navigation.NavController import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy -import com.crisiscleanup.sandbox.navigation.ASYNC_IMAGE_ROUTE +import com.crisiscleanup.sandbox.navigation.ROW_BADGE_ROUTE import com.crisiscleanup.sandbox.navigation.SandboxNavHost import com.crisiscleanup.sandbox.navigation.navigateToAsyncImage import com.crisiscleanup.sandbox.navigation.navigateToBottomNav import com.crisiscleanup.sandbox.navigation.navigateToCheckboxes import com.crisiscleanup.sandbox.navigation.navigateToChips import com.crisiscleanup.sandbox.navigation.navigateToMultiImage +import com.crisiscleanup.sandbox.navigation.navigateToRowBadge import com.crisiscleanup.sandbox.navigation.navigateToSingleImage @Composable @@ -66,7 +67,7 @@ fun SandboxApp( SandboxNavHost( appState.navController, appState::onBack, - ASYNC_IMAGE_ROUTE, + ROW_BADGE_ROUTE, ) } } @@ -102,6 +103,9 @@ fun RootRoute(navController: NavController) { CrisisCleanupTextButton(text = "Async image") { navController.navigateToAsyncImage() } + CrisisCleanupTextButton(text = "Row badge") { + navController.navigateToRowBadge() + } } } } diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt index 4cd9cb05..af8945a1 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/navigation/SandboxNavigation.kt @@ -11,6 +11,7 @@ import com.crisiscleanup.sandbox.ui.BottomNavRoute import com.crisiscleanup.sandbox.ui.CheckboxesRoute import com.crisiscleanup.sandbox.ui.ChipsRoute import com.crisiscleanup.sandbox.ui.MultiImageRoute +import com.crisiscleanup.sandbox.ui.RowBadgeView import com.crisiscleanup.sandbox.ui.SingleImageRoute const val ROOT_ROUTE = "root" @@ -20,6 +21,7 @@ private const val BOTTOM_NAV_ROUTE = "bottom-nav" const val SINGLE_IMAGE_ROUTE = "single-image" const val MULTI_IMAGE_ROUTE = "multi-image" const val ASYNC_IMAGE_ROUTE = "async-image" +const val ROW_BADGE_ROUTE = "row-badge" fun NavController.navigateToBottomNav() { this.navigate(BOTTOM_NAV_ROUTE) @@ -45,6 +47,10 @@ fun NavController.navigateToAsyncImage() { this.navigate(ASYNC_IMAGE_ROUTE) } +fun NavController.navigateToRowBadge() { + this.navigate(ROW_BADGE_ROUTE) +} + @Composable fun SandboxNavHost( navController: NavHostController, @@ -82,5 +88,9 @@ fun SandboxNavHost( composable(ASYNC_IMAGE_ROUTE) { AsyncImageView() } + + composable(ROW_BADGE_ROUTE) { + RowBadgeView() + } } } diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt new file mode 100644 index 00000000..75039ee6 --- /dev/null +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/ui/RowBadgeView.kt @@ -0,0 +1,116 @@ +package com.crisiscleanup.sandbox.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +@Composable +private fun RowScope.BadgedText( + alignment: Alignment, + text: String, +) { + BadgedBox( + { + Badge( + Modifier + .align(alignment) + .size(20.dp), + containerColor = Color.Red, + ) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = null, + ) + } + }, + Modifier + .background(Color.LightGray) + .weight(1f), + ) { + Text( + text, + Modifier + .align(Alignment.CenterStart) + .background(Color.Yellow), + ) + } +} + +@Composable +private fun RowBadge( + alignment: Alignment, + text: String, + buttonText: String = "Press me", +) { + Row( + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + BadgedText(alignment, text) + Button({}) { + Text(buttonText) + } + } +} + +@Composable +private fun ReverseRowBadge( + alignment: Alignment, + text: String, + buttonText: String = "Press me", +) { + Row( + Modifier + .fillMaxWidth() + .padding(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Button({}) { + Text(buttonText) + } + BadgedText(alignment, text) + } +} + +@Composable +fun RowBadgeView() { + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + RowBadge(Alignment.TopStart, "Top start") + RowBadge(Alignment.TopCenter, "Top center") + RowBadge(Alignment.TopEnd, "Top end") + RowBadge(Alignment.BottomEnd, "Bottom end") + RowBadge(Alignment.BottomCenter, "Bottom center") + RowBadge(Alignment.BottomStart, "Bottom start") + ReverseRowBadge(Alignment.TopStart, "Top start") + ReverseRowBadge(Alignment.TopCenter, "Top center") + ReverseRowBadge(Alignment.TopEnd, "Top end") + ReverseRowBadge(Alignment.BottomEnd, "Bottom end") + ReverseRowBadge(Alignment.BottomCenter, "Bottom center") + ReverseRowBadge(Alignment.BottomStart, "Bottom start") + } +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7acdabb0..0792389e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 268 + val buildVersion = 277 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt index 2f6eef5c..ac817dd7 100644 --- a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt +++ b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt @@ -20,7 +20,7 @@ class CrisisCleanupAppEnv @Inject constructor( val apiUrl = settingsProvider.apiBaseUrl return when { apiUrl.startsWith("https://api.dev.crisiscleanup.io") -> "Dev" - apiUrl.startsWith("https://api.staging.crisiscleanup.io") -> "Staging" + apiUrl.startsWith("https://crisiscleanup-3-api-staging.up.railway.app") -> "Staging" apiUrl.startsWith("https://api.crisiscleanup.org") -> "Production" else -> "Local?" } diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 0af36a58..2f00f0a5 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -1,17 +1,14 @@ package com.crisiscleanup import android.content.Intent -import android.graphics.Color import android.os.Bundle import androidx.activity.ComponentActivity -import androidx.activity.SystemBarStyle import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.activity.viewModels import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass -import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -24,7 +21,6 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.metrics.performance.JankStats import com.crisiscleanup.MainActivityViewState.Loading -import com.crisiscleanup.MainActivityViewState.Success import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.common.PermissionManager import com.crisiscleanup.core.common.PhoneNumberPicker @@ -38,7 +34,6 @@ import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme -import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.sync.initializers.scheduleSyncWorksites import com.crisiscleanup.ui.CrisisCleanupApp import com.crisiscleanup.ui.rememberCrisisCleanupAppState @@ -118,7 +113,7 @@ class MainActivity : ComponentActivity() { } setContent { - val darkTheme = shouldUseDarkTheme(viewState) + val darkTheme = isSystemInDarkTheme() val windowSizeClass = calculateWindowSizeClass(this) val appState = rememberCrisisCleanupAppState( @@ -126,10 +121,7 @@ class MainActivity : ComponentActivity() { windowSizeClass = windowSizeClass, ) - enableEdgeToEdge( - statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - navigationBarStyle = SystemBarStyle.dark(Color.TRANSPARENT), - ) + enableEdgeToEdge() CompositionLocalProvider { CrisisCleanupTheme( @@ -217,19 +209,3 @@ class MainActivity : ComponentActivity() { } } } - -/** - * Returns `true` if dark theme should be used, as a function of the [viewState] and the - * current system context. - */ -@Composable -private fun shouldUseDarkTheme( - viewState: MainActivityViewState, -): Boolean = when (viewState) { - Loading -> isSystemInDarkTheme() - is Success -> when (viewState.userData.darkThemeConfig) { - DarkThemeConfig.FOLLOW_SYSTEM -> isSystemInDarkTheme() - DarkThemeConfig.LIGHT -> false - DarkThemeConfig.DARK -> true - } -} diff --git a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt index ecaaadbc..29a61b10 100644 --- a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt +++ b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt @@ -123,20 +123,17 @@ class CrisisCleanupInterceptorProvider @Inject constructor( .build() } - private fun isExpiredToken(response: Response, logPaths: String): Pair { + private fun isExpiredToken(response: Response): Pair { if (response.code == 401) { return Pair(true, response) } - response.body?.let { responseBody -> + response.body.let { responseBody -> val body = responseBody.string() val errors = json.parseNetworkErrors(body) val bodyCopy = body.toResponseBody(responseBody.contentType()) val copyResponse = response.newBuilder().body(bodyCopy).build() return Pair(errors.hasExpiredToken, copyResponse) } - // TODO If body is null from above wouldn't response need to close (and rebuild)? - logger.logCapture("Token was not expired and body was null for $logPaths. Incoming exception?") - return Pair(false, response) } private val invalidRefreshTokenErrorMessages = setOf( @@ -169,10 +166,7 @@ class CrisisCleanupInterceptorProvider @Inject constructor( private fun tryAuthRequest(chain: Interceptor.Chain, request: Request): Response { val response = chain.proceed(request) - val (isExpired, nextResponse) = isExpiredToken( - response, - request.pathsForLog, - ) + val (isExpired, nextResponse) = isExpiredToken(response) if (isExpired) { logger.logCapture("Expired token trying refresh ${request.pathsForLog}") runBlocking { diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 1d210ffa..5421ae0b 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -59,7 +59,6 @@ import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.LocalDimensions -import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.ui.AppLayoutArea import com.crisiscleanup.core.ui.LayoutSizePosition @@ -147,6 +146,11 @@ private fun BoxScope.LoadedContent( val orgUserInviteCode by viewModel.orgUserInvites.collectAsStateWithLifecycle("") val showOrgInviteTransfer = orgUserInviteCode.isNotBlank() + val contentModifier = Modifier + .semantics { + testTagsAsResourceId = true + } + if (openAuthentication || isNotAuthenticatedState ) { @@ -158,6 +162,7 @@ private fun BoxScope.LoadedContent( appState, !isNotAuthenticatedState, toggleAuthentication, + modifier = contentModifier, ) if (isNotAuthenticatedState) { @@ -196,6 +201,7 @@ private fun BoxScope.LoadedContent( isLoading, viewModel.isAcceptingTerms, setAcceptingTerms, + contentModifier, onRejectTerms = viewModel::onRejectTerms, onAcceptTerms = viewModel::onAcceptTerms, errorMessage = viewModel.acceptTermsErrorMessage, @@ -220,6 +226,7 @@ private fun BoxScope.LoadedContent( isOnboarding = isOnboarding, menuTutorialStep, viewModel.tutorialViewTracker.viewSizePositionLookup, + contentModifier, viewModel::onMenuTutorialNext, ) { openAuthentication = true } @@ -261,33 +268,52 @@ private fun BoxScope.LoadedContent( } @Composable -private fun AuthenticateContent( +private fun ScaffoldBox( snackbarHostState: SnackbarHostState, - appState: CrisisCleanupAppState, - enableBackHandler: Boolean, - toggleAuthentication: (Boolean) -> Unit, + modifier: Modifier = Modifier, + content: @Composable () -> Unit = {}, ) { Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, + modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, contentWindowInsets = WindowInsets.systemBars, snackbarHost = { SnackbarHost(snackbarHostState) }, ) { padding -> - CrisisCleanupAuthNavHost( - navController = appState.navController, - enableBackHandler = enableBackHandler, - closeAuthentication = { toggleAuthentication(false) }, - onBack = appState::onBack, - modifier = Modifier + Box( + Modifier .fillMaxSize() .padding(padding) .consumeWindowInsets(padding) .windowInsetsPadding( WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), ), + ) { + content() + } + } +} + +@Composable +private fun AuthenticateContent( + snackbarHostState: SnackbarHostState, + appState: CrisisCleanupAppState, + enableBackHandler: Boolean, + toggleAuthentication: (Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + ScaffoldBox( + snackbarHostState, + modifier, + ) { + CrisisCleanupAuthNavHost( + navController = appState.navController, + enableBackHandler = enableBackHandler, + closeAuthentication = { toggleAuthentication(false) }, + onBack = appState::onBack, + modifier = Modifier + .background(Color.White) + .fillMaxSize(), ) } } @@ -300,19 +326,15 @@ private fun AcceptTermsContent( isLoading: Boolean, isAcceptingTerms: Boolean, setAcceptingTerms: (Boolean) -> Unit, + modifier: Modifier = Modifier, onRejectTerms: () -> Unit = {}, onAcceptTerms: () -> Unit = {}, errorMessage: String = "", ) { - Scaffold( - modifier = Modifier.semantics { - testTagsAsResourceId = true - }, - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - contentWindowInsets = WindowInsets.systemBars, - snackbarHost = { SnackbarHost(snackbarHostState) }, - ) { padding -> + ScaffoldBox( + snackbarHostState, + modifier, + ) { AcceptTermsView( termsOfServiceUrl, privacyPolicyUrl, @@ -320,12 +342,8 @@ private fun AcceptTermsContent( isAcceptingTerms = isAcceptingTerms, setAcceptingTerms = setAcceptingTerms, modifier = Modifier - .fillMaxSize() - .padding(padding) - .consumeWindowInsets(padding) - .windowInsetsPadding( - WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal), - ), + .background(Color.White) + .fillMaxSize(), onRejectTerms = onRejectTerms, onAcceptTerms = onAcceptTerms, errorMessage = errorMessage, @@ -341,6 +359,7 @@ private fun NavigableContent( isOnboarding: Boolean, menuTutorialStep: TutorialStep, tutorialViewLookup: SnapshotStateMap, + modifier: Modifier = Modifier, advanceMenuTutorial: () -> Unit, openAuthentication: () -> Unit, ) { @@ -353,11 +372,7 @@ private fun NavigableContent( } Scaffold( - modifier = Modifier - .background(navigationContainerColor) - .semantics { - testTagsAsResourceId = true - }, + modifier = modifier, containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, contentWindowInsets = WindowInsets(0, 0, 0, 0), diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt index 3434f2bc..54ba261c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentSelector.kt @@ -12,11 +12,9 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -46,11 +44,7 @@ class IncidentSelectManager @Inject constructor( ::Pair, ) .mapLatest { (incidents, accountData) -> - if (accountData.isCrisisCleanupAdmin) { - incidents - } else { - incidents.filter { accountData.approvedIncidents.contains(it.id) } - } + accountData.filterApproved(incidents) } private val preferencesIncidentId = @@ -65,7 +59,6 @@ class IncidentSelectManager @Inject constructor( ) .map { (selectedId, incidents) -> incidents.firstOrNull { it.id == selectedId } - ?: incidents.firstOrNull() ?: EmptyIncident } @@ -102,22 +95,6 @@ class IncidentSelectManager @Inject constructor( started = subscribedReplay(), ) - init { - combine( - preferencesIncidentId, - incidentsSource, - ::Pair, - ) - .onEach { (selectedId, incidents) -> - val selectedIncident = incidents.find { it.id == selectedId } ?: EmptyIncident - if (selectedIncident == EmptyIncident && incidents.isNotEmpty()) { - val firstIncident = incidents[0] - appPreferencesRepository.setSelectedIncident(firstIncident.id) - } - } - .launchIn(coroutineScope) - } - override fun selectIncident(incident: Incident) { coroutineScope.launch { submitIncidentChange(incident) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index 0ec74e1b..e9102a4c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -12,6 +12,7 @@ import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.radians +import com.crisiscleanup.core.common.split import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.core.data.IncidentMapTracker @@ -42,6 +43,7 @@ import com.crisiscleanup.core.model.data.IncidentWorksitesCachePreferences import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.KeyDynamicValuePair import com.crisiscleanup.core.network.model.NetworkFlagsFormData +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import com.crisiscleanup.core.network.model.NetworkWorksitePage import com.crisiscleanup.core.network.model.WorksiteDataResult @@ -54,13 +56,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.datetime.Clock import kotlinx.datetime.Instant -import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds import kotlinx.coroutines.flow.combine as kCombine @@ -489,6 +491,18 @@ class IncidentWorksitesCacheRepository @Inject constructor( ensureActive() worksitesAdditionalStatsUpdater.clearStep() } + + if (!(isPaused || isSlowDownload)) { + ensureActive() + + logStage(incidentId, IncidentCacheStage.WorksitesChangedIncident) + + updateChangedIncidentWorksites( + incidentId, + syncPlan.restartCache, + syncPreferences.lastReconciled, + ) + } } catch (e: Exception) { with(incidentDataPullStats.value) { if (queryCount < dataCount) { @@ -869,8 +883,13 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater, downloadSpeedTracker, getTotalCaseCount = null, - { count: Int, before: Instant -> - networkDataSource.getWorksitesPageBefore(incidentId, count, before) + { count: Int, offset: Int, before: Instant -> + networkDataSource.getWorksitesPageBefore( + incidentId, + pageCount = count, + updatedBefore = before, + offset = offset, + ) }, { worksites: List -> saveWorksites(worksites, statsUpdater) @@ -898,8 +917,13 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers, statsUpdater, downloadSpeedTracker, - { count: Int, after: Instant -> - networkDataSource.getWorksitesPageAfter(incidentId, count, after) + { count: Int, offset: Int, after: Instant -> + networkDataSource.getWorksitesPageAfter( + incidentId, + pageCount = count, + updatedAfter = after, + offset = offset, + ) }, { worksites: List -> saveWorksites(worksites, statsUpdater) @@ -921,7 +945,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater: IncidentDataPullStatsUpdater, downloadSpeedTracker: CountTimeTracker, getTotalCaseCount: (suspend () -> Int)?, - getNetworkData: suspend (Int, Instant) -> T, + getNetworkData: suspend (Int, Int, Instant) -> T, saveToDb: suspend (List) -> Unit, ) where T : WorksiteDataResult, U : WorksiteDataSubset = coroutineScope { var isSlowDownload: Boolean? = null @@ -930,9 +954,10 @@ class IncidentWorksitesCacheRepository @Inject constructor( log("Downloading Worksites before") - var queryCount = if (isPaused) 100 else 1000 - val maxQueryCount = getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) - var beforeTimeMarker = timeMarkers.before + val queryCount = + if (isPaused) 100 else getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + var queryOffset = 0 + val beforeTimeMarker = timeMarkers.before var savedWorksiteIds = emptySet() var initialCount = -1 var savedCount = 0 @@ -941,9 +966,9 @@ class IncidentWorksitesCacheRepository @Inject constructor( ensureActive() val networkData = downloadSpeedTracker.time { - // TODO Edge case where paging data breaks where Cases are equally updated_at val result = getNetworkData( queryCount, + queryOffset, beforeTimeMarker, ) @@ -992,16 +1017,16 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryCount = (queryCount * 2).coerceAtMost(maxQueryCount) - beforeTimeMarker = networkData.last().updatedAt - + val lastTimeMarker = networkData.last().updatedAt.plus(1.minutes) if (stage == IncidentCacheStage.WorksitesCore) { - syncParameterDao.updateUpdatedBefore(incidentId, beforeTimeMarker) + syncParameterDao.updateUpdatedBefore(incidentId, lastTimeMarker) } else { - syncParameterDao.updateAdditionalUpdatedBefore(incidentId, beforeTimeMarker) + syncParameterDao.updateAdditionalUpdatedBefore(incidentId, lastTimeMarker) } - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $beforeTimeMarker") + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) before, back to $lastTimeMarker ($queryOffset-$queryCount)") + + queryOffset += queryCount } if (isPaused) { @@ -1026,30 +1051,30 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers: IncidentDataSyncParameters.SyncTimeMarker, statsUpdater: IncidentDataPullStatsUpdater, downloadSpeedTracker: CountTimeTracker, - getNetworkData: suspend (Int, Instant) -> T, + getNetworkData: suspend (Int, Int, Instant) -> T, saveToDb: suspend (List) -> Unit, ) where T : WorksiteDataResult, U : WorksiteDataSubset = coroutineScope { var isSlowDownload: Boolean? = null fun log(message: String) = logStage(incidentId, stage, message) - var afterTimeMarker = timeMarkers.after - - log("Downloading delta starting at $afterTimeMarker") - - var queryCount = if (isPaused) 100 else 1000 - val maxQueryCount = getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + val queryCount = + if (isPaused) 100 else getMaxQueryCount(stage == IncidentCacheStage.WorksitesAdditional) + var queryOffset = 0 + val afterTimeMarker = timeMarkers.after var savedWorksiteIds = emptySet() var initialCount = -1 var savedCount = 0 + log("Downloading delta starting at $afterTimeMarker") + do { ensureActive() val networkData = downloadSpeedTracker.time { - // TODO Edge case where paging data breaks where Cases are equally updated_at val result = getNetworkData( queryCount, + queryOffset, afterTimeMarker, ) if (initialCount < 0) { @@ -1070,7 +1095,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( if (networkData.isEmpty()) { updateUpdatedAfter(syncStart) - log("Cached $savedCount/$initialCount after. No Cases after $afterTimeMarker") + log("Cached $savedCount/$initialCount after. No Cases after $afterTimeMarker ($queryOffset-$queryCount)") } else { downloadSpeedTracker.averageSpeed()?.let { val isSlow = it < slowDownloadSpeed @@ -1093,12 +1118,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( savedWorksiteIds = networkData.map { it.id }.toSet() - queryCount = (queryCount * 2).coerceAtMost(maxQueryCount) - afterTimeMarker = networkData.last().updatedAt + val lastTimeMarker = networkData.last().updatedAt.minus(1.minutes) + updateUpdatedAfter(lastTimeMarker) - updateUpdatedAfter(afterTimeMarker) + log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $lastTimeMarker ($queryOffset-$queryCount)") - log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $afterTimeMarker") + queryOffset += queryCount } if (isPaused) { @@ -1183,11 +1208,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( statsUpdater, downloadSpeedTracker, getTotalCaseCount = { worksitesRepository.getWorksitesCount(incidentId) }, - { count: Int, before: Instant -> + { count: Int, offset: Int, before: Instant -> networkDataSource.getWorksitesFlagsFormDataPageBefore( incidentId, count, before, + offset = offset, ) }, { worksites: List -> @@ -1216,11 +1242,12 @@ class IncidentWorksitesCacheRepository @Inject constructor( timeMarkers, statsUpdater, downloadSpeedTracker, - { count: Int, after: Instant -> + { count: Int, offset: Int, after: Instant -> networkDataSource.getWorksitesFlagsFormDataPageAfter( incidentId, count, after, + offset = offset, ) }, { worksites: List -> @@ -1278,7 +1305,41 @@ class IncidentWorksitesCacheRepository @Inject constructor( } override suspend fun updateCachePreferences(preferences: IncidentWorksitesCachePreferences) { - incidentCachePreferences.setPreferences(preferences) + incidentCachePreferences.setPauseRegionPreferences(preferences) + } + + private suspend fun updateChangedIncidentWorksites( + incidentId: Long, + restartCache: Boolean, + lastReconciled: Instant, + ) { + val minTimestamp = Clock.System.now().minus(45.days) + val queryAfter = + if (restartCache) minTimestamp else lastReconciled.coerceAtLeast(minTimestamp) + try { + val reconcileStart = Clock.System.now() + + val worksiteChanges = networkDataSource.getWorksiteChanges(queryAfter) + + val (valid, invalid) = worksiteChanges.split { it.invalidatedAt == null } + val invalidWorksiteIds = invalid.map(NetworkWorksiteChange::worksiteId) + + val localChanges = worksitesRepository.processReconciliation( + valid.toList(), + invalidWorksiteIds, + ) + if (localChanges.isNotEmpty()) { + logStage( + incidentId, + IncidentCacheStage.WorksitesChangedIncident, + "${localChanges.size} Cases changed Incidents or were deleted.", + ) + } + + incidentCachePreferences.setLastReconciled(reconcileStart) + } catch (e: Exception) { + appLogger.logException(e) + } } private data class DownloadCountSpeed( @@ -1297,6 +1358,7 @@ enum class IncidentCacheStage { WorksitesAdditional, ActiveIncident, ActiveIncidentOrganization, + WorksitesChangedIncident, End, } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 2b081aa8..1063dbb3 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -18,7 +18,9 @@ import com.crisiscleanup.core.database.dao.LocationDaoPlus import com.crisiscleanup.core.database.dao.fts.getMatchingIncidents import com.crisiscleanup.core.database.model.PopulatedIncident import com.crisiscleanup.core.database.model.asExternalModel +import com.crisiscleanup.core.datastore.AccountInfoDataSource import com.crisiscleanup.core.datastore.LocalAppPreferencesDataSource +import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.INCIDENT_ORGANIZATIONS_STABLE_MODEL_BUILD_VERSION import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.model.data.IncidentIdNameType @@ -30,6 +32,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.withContext import kotlinx.datetime.Clock @@ -47,6 +50,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( private val incidentOrganizationDao: IncidentOrganizationDao, private val incidentOrganizationsSyncer: IncidentOrganizationsSyncer, private val appPreferences: LocalAppPreferencesDataSource, + private val accountInfoDataSource: AccountInfoDataSource, inputValidator: InputValidator, @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @@ -214,13 +218,16 @@ class OfflineFirstIncidentsRepository @Inject constructor( } override suspend fun pullIncidents(force: Boolean) = coroutineScope { - var isSuccessful = false - try { - syncInternal(force) - isSuccessful = true - } finally { - // Treat coroutine cancellation as unsuccessful for now - appPreferences.setSyncAttempt(isSuccessful) + syncInternal(force) + + val selectedIncidentId = appPreferences.userData.first().selectedIncidentId + if (selectedIncidentId == EmptyIncident.id) { + val incidents = getIncidentsList() + val accountData = accountInfoDataSource.accountData.first() + val approvedIncidents = accountData.filterApproved(incidents) + approvedIncidents.firstOrNull()?.let { firstIncident -> + appPreferences.setSelectedIncident(firstIncident.id) + } } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 917b9e20..e8bb6761 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -12,11 +12,13 @@ import com.crisiscleanup.core.database.dao.RecentWorksiteDao import com.crisiscleanup.core.database.dao.WorkTypeTransferRequestDaoPlus import com.crisiscleanup.core.database.dao.WorksiteDao import com.crisiscleanup.core.database.dao.WorksiteDaoPlus +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.PopulatedRecentWorksite import com.crisiscleanup.core.database.model.RecentWorksiteEntity import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.database.model.asSummary import com.crisiscleanup.core.model.data.CasesFilter +import com.crisiscleanup.core.model.data.EmptyWorksite import com.crisiscleanup.core.model.data.IncidentIdWorksiteCount import com.crisiscleanup.core.model.data.OrganizationLocationAreaBounds import com.crisiscleanup.core.model.data.TableDataWorksite @@ -24,6 +26,7 @@ import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.getClaimStatus import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.CrisisCleanupWriteApi +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview @@ -334,4 +337,23 @@ class OfflineFirstWorksitesRepository @Inject constructor( tableData } + + override suspend fun processReconciliation( + validChanges: List, + invalidatedNetworkWorksiteIds: List, + ): List { + val validIds = validChanges.map { + IncidentWorksiteIds( + incidentId = it.incidentId, + worksiteId = EmptyWorksite.id, + networkWorksiteId = it.worksiteId, + ) + } + val worksitesChanged = worksiteDaoPlus.syncNetworkChangedIncidents(validIds) + val worksitesDeleted = worksiteDaoPlus.syncDeletedWorksites(invalidatedNetworkWorksiteIds) + + return worksitesChanged.toMutableList().apply { + addAll(worksitesDeleted) + } + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt index a62316e5..75b3afce 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.data.repository +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.model.data.CasesFilter import com.crisiscleanup.core.model.data.IncidentIdWorksiteCount import com.crisiscleanup.core.model.data.LocalWorksite @@ -8,6 +9,7 @@ import com.crisiscleanup.core.model.data.Worksite import com.crisiscleanup.core.model.data.WorksiteMapMark import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.WorksiteSummary +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteFull import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Clock @@ -85,4 +87,9 @@ interface WorksitesRepository { searchRadius: Float = 100f, count: Int = 360, ): List + + suspend fun processReconciliation( + validChanges: List, + invalidatedNetworkWorksiteIds: List, + ): List } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt index 50bc6a6f..f9bcabb0 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt @@ -5,6 +5,7 @@ import androidx.room.Database import androidx.room.Query import androidx.room.Transaction import androidx.room.TypeConverters +import com.crisiscleanup.core.database.dao.RecentWorksiteDao import com.crisiscleanup.core.database.dao.fts.IncidentFtsEntity import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity @@ -17,6 +18,7 @@ import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef import com.crisiscleanup.core.database.model.IncidentLocationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationSyncStatsEntity +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.LanguageTranslationEntity @@ -108,6 +110,7 @@ abstract class TestCrisisCleanupDatabase : CrisisCleanupDatabase() { abstract fun testWorkTypeDao(): TestWorkTypeDao abstract fun testWorksiteChangeDao(): TestWorksiteChangeDao abstract fun testWorkTypeRequestDao(): TestWorkTypeRequestDao + abstract fun testRecentWorksiteDao(): TestRecentWorksiteDao } @Dao @@ -163,6 +166,18 @@ interface TestWorksiteDao { limit: Int, offset: Int = 0, ): List + + @Transaction + @Query("SELECT * FROM worksites ORDER BY network_id") + fun getWorksites(): List + + @Transaction + @Query("SELECT id, incident_id, network_id FROM worksites_root ORDER BY id") + fun getRootWorksiteEntities(): List + + @Transaction + @Query("SELECT id, incident_id, network_id FROM worksites ORDER BY id") + fun getWorksiteEntities(): List } @Dao @@ -256,3 +271,10 @@ interface TestWorkTypeRequestDao { @Query("SELECT id, network_id FROM worksite_work_type_requests WHERE worksite_id=:worksiteId") fun getNetworkedIdMap(worksiteId: Long): List } + +@Dao +interface TestRecentWorksiteDao : RecentWorksiteDao { + @Transaction + @Query("SELECT * FROM recent_worksites ORDER BY id") + fun getRecentWorksites(): List +} diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt new file mode 100644 index 00000000..b183c312 --- /dev/null +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncReconciliationTest.kt @@ -0,0 +1,162 @@ +package com.crisiscleanup.core.database.dao + +import com.crisiscleanup.core.database.TestCrisisCleanupDatabase +import com.crisiscleanup.core.database.TestRecentWorksiteDao +import com.crisiscleanup.core.database.TestUtil +import com.crisiscleanup.core.database.TestUtil.testAppLogger +import com.crisiscleanup.core.database.TestUtil.testSyncLogger +import com.crisiscleanup.core.database.TestWorksiteDao +import com.crisiscleanup.core.database.WorksiteTestUtil +import com.crisiscleanup.core.database.WorksiteTestUtil.testIncidents +import com.crisiscleanup.core.database.model.IncidentWorksiteIds +import com.crisiscleanup.core.database.model.RecentWorksiteEntity +import com.crisiscleanup.core.database.model.WorksiteEntity +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours + +class WorksiteSyncReconciliationTest { + private lateinit var db: TestCrisisCleanupDatabase + + private lateinit var worksiteDao: TestWorksiteDao + private lateinit var worksiteDaoPlus: WorksiteDaoPlus + private lateinit var recentWorksiteDao: TestRecentWorksiteDao + + private val syncLogger = testSyncLogger() + private val appLogger = testAppLogger() + + @Before + fun createDb() { + db = TestUtil.getTestDatabase() + worksiteDao = db.testWorksiteDao() + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) + recentWorksiteDao = db.testRecentWorksiteDao() + } + + @Before + fun seedDb() = runTest { + val incidentDao = db.incidentDao() + incidentDao.upsertIncidents(testIncidents) + + val worksiteCreatedAt = Clock.System.now().minus(10.days) + val insertAt = Clock.System.now().minus(1.days) + insertWorksites( + listOf( + testWorksiteFullEntity( + 534, + 23, + worksiteCreatedAt.plus(1.hours), + ), + testWorksiteFullEntity( + 48, + 1, + worksiteCreatedAt.plus(2.hours), + ), + testWorksiteFullEntity( + 1654, + 456, + worksiteCreatedAt.plus(3.hours), + ), + testWorksiteFullEntity( + 9, + 23, + worksiteCreatedAt.plus(4.hours), + ), + testWorksiteFullEntity( + 987, + 23, + worksiteCreatedAt.plus(5.hours), + ), + ), + insertAt, + ) + } + + private suspend fun insertWorksites( + worksites: List, + syncedAt: Instant, + ) = WorksiteTestUtil.insertWorksites( + db, + syncedAt, + *worksites.toTypedArray(), + ) + + @Test + fun syncNetworkChangedIncidents() = runTest { + val viewedAt = Instant.fromEpochSeconds(1756835957) + val recentViews = listOf( + RecentWorksiteEntity(4, 23, viewedAt), + RecentWorksiteEntity(1, 23, viewedAt), + ) + for (recent in recentViews) { + recentWorksiteDao.upsert(recent) + } + + fun makeChangeIds(incidentId: Long, networkWorksiteId: Long) = + IncidentWorksiteIds( + incidentId = incidentId, + worksiteId = 0, + networkWorksiteId = networkWorksiteId, + ) + + val changes = worksiteDaoPlus.syncNetworkChangedIncidents( + listOf( + makeChangeIds(1, 534), + makeChangeIds(1, 8921), + makeChangeIds(1, 987), + makeChangeIds(1, 4986), + makeChangeIds(23, 1654), + ), + stepInterval = 2, + ) + + val expectedChanges = listOf( + IncidentWorksiteIds(23, 1, 534), + IncidentWorksiteIds(23, 5, 987), + IncidentWorksiteIds(456, 3, 1654), + ) + assertEquals(expectedChanges, changes) + + val orderedChanges = listOf( + IncidentWorksiteIds(1, 1, 534), + IncidentWorksiteIds(1, 2, 48), + IncidentWorksiteIds(23, 3, 1654), + IncidentWorksiteIds(23, 4, 9), + IncidentWorksiteIds(1, 5, 987), + ) + val worksiteIdsA = worksiteDao.getWorksiteEntities() + assertEquals(orderedChanges, worksiteIdsA) + val worksiteIdsB = worksiteDao.getRootWorksiteEntities() + assertEquals(orderedChanges, worksiteIdsB) + + val recents = recentWorksiteDao.getRecentWorksites() + val expectedRecents = listOf( + RecentWorksiteEntity(1, 1, viewedAt), + RecentWorksiteEntity(4, 23, viewedAt), + ) + assertEquals(expectedRecents, recents) + } + + @Test + fun syncDeletedWorksites() = runTest { + val changes = worksiteDaoPlus.syncDeletedWorksites( + listOf(987, 9, 54, 13, 654, 7895, 48), + stepInterval = 2, + ) + val expectedChanges = listOf( + IncidentWorksiteIds(23, 5, 987), + IncidentWorksiteIds(23, 4, 9), + IncidentWorksiteIds(1, 2, 48), + ) + assertEquals(expectedChanges, changes) + + val worksites = worksiteDao.getWorksites() + val networkWorksiteIds = worksites.map { it.entity.networkId } + assertEquals(listOf(534L, 1654), networkWorksiteIds) + } +} diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt index 4967663e..38a4077f 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/RecentWorksiteDao.kt @@ -25,7 +25,7 @@ interface RecentWorksiteDao { ) fun streamRecentWorksites( incidentId: Long, - limit: Int = 16, + limit: Int = 30, offset: Int = 0, ): Flow> @@ -47,4 +47,8 @@ interface RecentWorksiteDao { @Upsert fun upsert(recentWorksite: RecentWorksiteEntity) + + @Transaction + @Query("UPDATE recent_worksites SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateRecentWorksiteIncident(id: Long, incidentId: Long) } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt index 2899131f..f9cc23ac 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt @@ -7,6 +7,7 @@ import androidx.room.Transaction import androidx.room.Update import com.crisiscleanup.core.database.dao.fts.PopulatedWorksiteTextMatchInfo import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.PopulatedFilterDataWorksite import com.crisiscleanup.core.database.model.PopulatedLocalModifiedAt import com.crisiscleanup.core.database.model.PopulatedLocalWorksite @@ -618,6 +619,28 @@ interface WorksiteDao { offset: Int, ): List + @Transaction + @Query( + """ + SELECT id, incident_id, network_id + FROM worksites_root + WHERE network_id IN(:networkWorksiteIds) + """, + ) + fun getWorksiteIds(networkWorksiteIds: List): List + + @Transaction + @Query("UPDATE worksites_root SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateWorksiteRootIncident(id: Long, incidentId: Long) + + @Transaction + @Query("UPDATE worksites SET incident_id=:incidentId WHERE id=:id") + fun syncUpdateWorksiteIncident(id: Long, incidentId: Long) + + @Transaction + @Query("DELETE from worksites_root WHERE network_id IN(:networkWorksiteIds)") + fun deleteNetworkWorksites(networkWorksiteIds: Collection) + @Transaction @Query("SELECT case_number FROM worksites ORDER BY RANDOM() LIMIT 1") fun getRandomWorksiteCaseNumber(): String? diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index a70d2518..a1775e62 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -10,6 +10,7 @@ import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.database.CrisisCleanupDatabase import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds import com.crisiscleanup.core.database.model.CoordinateGridQuery +import com.crisiscleanup.core.database.model.IncidentWorksiteIds import com.crisiscleanup.core.database.model.NetworkFileEntity import com.crisiscleanup.core.database.model.PopulatedFilterDataWorksite import com.crisiscleanup.core.database.model.PopulatedLocalModifiedAt @@ -837,4 +838,66 @@ class WorksiteDaoPlus @Inject constructor( IncidentIdWorksiteCount(incidentId, totalCount, count) } + + suspend fun syncNetworkChangedIncidents( + changeCandidates: List, + stepInterval: Int = 100, + ) = db.withTransaction { + val changedWorksites = mutableListOf() + + val worksiteDao = db.worksiteDao() + val changedIncidentWorksites = mutableListOf() + val iStep = stepInterval.coerceAtLeast(1) + for (i in changeCandidates.indices step iStep) { + val iEnd = (i + iStep).coerceAtMost(changeCandidates.size) + val candidatesChunk = changeCandidates.subList(i, iEnd) + val queryIds = candidatesChunk.map(IncidentWorksiteIds::networkWorksiteId) + val localLookup = worksiteDao.getWorksiteIds(queryIds) + .associateBy(IncidentWorksiteIds::networkWorksiteId) + val chunkChanges = candidatesChunk.mapNotNull { candidate -> + localLookup[candidate.networkWorksiteId]?.let { localMatch -> + if (candidate.incidentId != localMatch.incidentId) { + changedWorksites.add(localMatch) + return@mapNotNull candidate.copy( + worksiteId = localMatch.worksiteId, + ) + } + } + null + } + changedIncidentWorksites.addAll(chunkChanges) + } + + val recentDao = db.recentWorksiteDao() + for (changed in changedIncidentWorksites) { + val id = changed.worksiteId + val incidentId = changed.incidentId + worksiteDao.syncUpdateWorksiteRootIncident(id, incidentId) + worksiteDao.syncUpdateWorksiteIncident(id, incidentId) + recentDao.syncUpdateRecentWorksiteIncident(id, incidentId) + } + + changedWorksites + } + + suspend fun syncDeletedWorksites(networkIds: List, stepInterval: Int = 100) = + db.withTransaction { + val deletedWorksites = mutableListOf() + + val iStep = stepInterval.coerceAtLeast(1) + val worksiteDao = db.worksiteDao() + for (i in networkIds.indices step iStep) { + val iEnd = (i + iStep).coerceAtMost(networkIds.size) + val idChunk = networkIds.subList(i, iEnd) + val localLookup = worksiteDao.getWorksiteIds(idChunk) + .associateBy(IncidentWorksiteIds::networkWorksiteId) + if (localLookup.isNotEmpty()) { + val deleteIds = localLookup.keys + db.worksiteDao().deleteNetworkWorksites(deleteIds) + deletedWorksites.addAll(idChunk.mapNotNull { localLookup[it] }) + } + } + + deletedWorksites + } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt new file mode 100644 index 00000000..8f475679 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentWorksiteIds.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.ColumnInfo + +// Used as db model and external model +// Names must remain consistent +data class IncidentWorksiteIds( + @ColumnInfo("incident_id") + val incidentId: Long, + @ColumnInfo("id") + val worksiteId: Long, + @ColumnInfo("network_id") + val networkWorksiteId: Long, +) diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto index 13b690c9..e64ebc86 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_metrics.proto @@ -7,6 +7,9 @@ option java_package = "com.crisiscleanup.core.datastore"; option java_multiple_files = true; message AppMetrics { + + // ** Other files use snake case not camel case ** + AppEndUseProto earlybirdBuildEnd = 1; int64 appOpenSeconds = 2; diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto index 054e1d64..b8a40e4c 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/indicent_cache_preferences.proto @@ -10,4 +10,5 @@ message IncidentCachePreferences { double region_longitude = 4; double region_radius_miles = 5; bool is_region_my_location = 6; + int64 case_reconciliation_seconds = 7; } diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index 6579db76..e0580b4e 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -18,14 +18,13 @@ message UserPreferences { DarkThemeConfigProto dark_theme_config = 2; - // General sync stats. Use for backoff in case of bad connection, errors, or other failures. - SyncAttemptProto sync_attempt = 3; + SyncAttemptProto sync_attempt = 3 [deprecated = true]; int64 selected_incident_id = 4; // Deprecated since OAuth and other auth options was added - int32 save_credentials_prompt_count = 5; - bool disable_save_credentials_prompt = 6; + int32 save_credentials_prompt_count = 5 [deprecated = true]; + bool disable_save_credentials_prompt = 6 [deprecated = true]; string language_key = 7; diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt index 22319e66..17bbc974 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/IncidentCachePreferencesDataSource.kt @@ -4,6 +4,7 @@ import androidx.datastore.core.DataStore import com.crisiscleanup.core.model.data.BoundedRegionParameters import com.crisiscleanup.core.model.data.IncidentWorksitesCachePreferences import kotlinx.coroutines.flow.map +import kotlinx.datetime.Instant import javax.inject.Inject class IncidentCachePreferencesDataSource @Inject constructor( @@ -19,10 +20,14 @@ class IncidentCachePreferencesDataSource @Inject constructor( regionLongitude = it.regionLongitude, regionRadiusMiles = it.regionRadiusMiles, ), + lastReconciled = Instant.fromEpochSeconds(it.caseReconciliationSeconds), ) } - suspend fun setPreferences(preferences: IncidentWorksitesCachePreferences) { + /** + * Updates preferences relating to pausing sync and region syncing + */ + suspend fun setPauseRegionPreferences(preferences: IncidentWorksitesCachePreferences) { dataStore.updateData { val regionParameters = preferences.boundedRegionParameters it.copy { @@ -35,4 +40,12 @@ class IncidentCachePreferencesDataSource @Inject constructor( } } } + + suspend fun setLastReconciled(lastReconciled: Instant) { + dataStore.updateData { + it.copy { + caseReconciliationSeconds = lastReconciled.epochSeconds + } + } + } } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index 4f49a6f3..9a167752 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -4,12 +4,10 @@ import androidx.datastore.core.DataStore import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.IncidentCoordinateBounds -import com.crisiscleanup.core.model.data.SyncAttempt import com.crisiscleanup.core.model.data.UserData import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.core.model.data.worksiteSortByFromLiteral import kotlinx.coroutines.flow.map -import kotlinx.datetime.Clock import javax.inject.Inject /** @@ -36,12 +34,6 @@ class LocalAppPreferencesDataSource @Inject constructor( }, shouldHideOnboarding = it.shouldHideOnboarding, - syncAttempt = SyncAttempt( - it.syncAttempt.successfulSeconds, - it.syncAttempt.attemptedSeconds, - it.syncAttempt.attemptedCounter, - ), - selectedIncidentId = if (it.selectedIncidentId <= 0L) EmptyIncident.id else it.selectedIncidentId, languageKey = it.languageKey, @@ -106,35 +98,6 @@ class LocalAppPreferencesDataSource @Inject constructor( } } - suspend fun setSyncAttempt( - isSuccessful: Boolean, - attemptedSeconds: Long = Clock.System.now().epochSeconds, - ) { - userPreferences.updateData { - val builder = SyncAttemptProto.newBuilder(it.syncAttempt) - if (isSuccessful) { - builder.successfulSeconds = attemptedSeconds - builder.attemptedCounter = 0 - } else { - builder.attemptedCounter++ - } - builder.attemptedSeconds = attemptedSeconds - val attempt = builder.build() - - it.copy { - syncAttempt = attempt - } - } - } - - suspend fun clearSyncData() { - userPreferences.updateData { - it.copy { - syncAttempt = SyncAttemptProto.newBuilder().build() - } - } - } - suspend fun setSelectedIncident(id: Long) { userPreferences.updateData { it.copy { selectedIncidentId = id } diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt index 80c71e51..5ff313d3 100644 --- a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSourceTest.kt @@ -1,21 +1,12 @@ package com.crisiscleanup.core.datastore import com.crisiscleanup.core.datastore.test.testUserPreferencesDataStore -import com.crisiscleanup.core.model.data.SyncAttempt -import com.crisiscleanup.core.model.data.UserData import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.toList -import kotlinx.coroutines.launch -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import kotlinx.coroutines.yield import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder -import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -42,90 +33,4 @@ class LocalAppPreferencesDataSourceTest { subject.setShouldHideOnboarding(true) assertTrue(subject.userData.first().shouldHideOnboarding) } - - @Test - fun syncAttemptDefault() = runTest { - val syncAttempt = SyncAttempt(0, 0, 0) - assertEquals(syncAttempt, subject.userData.first().syncAttempt) - } - - private fun setupSyncAttempt( - testBody: suspend TestScope.() -> Unit, - onAttempts: TestScope.(List) -> Unit, - ) = runTest { - val values = mutableListOf() - val collectJob = launch(UnconfinedTestDispatcher(testScheduler)) { - subject.userData.toList(values) - } - - try { - testBody() - - advanceUntilIdle() - yield() - - val attempts = values.map(UserData::syncAttempt) - onAttempts(attempts) - } finally { - collectJob.cancel() - } - } - - @Test - fun syncAttemptSuccessful() = runTest { - setupSyncAttempt( - { - subject.setSyncAttempt(true, 1582) - subject.setSyncAttempt(true, 19815) - subject.setSyncAttempt(false, 20158) - }, - ) { attempts: List -> - val expecteds = listOf( - SyncAttempt(0, 0, 0), - SyncAttempt(1582, 1582, 0), - SyncAttempt(19815, 19815, 0), - SyncAttempt(19815, 20158, 1), - ) - for (i in expecteds.indices) { - assertEquals(expecteds[i], attempts[i]) - } - } - } - - @Test - fun syncAttemptFail() = runTest { - setupSyncAttempt( - { - subject.setSyncAttempt(false, 1582) - subject.setSyncAttempt(false, 19815) - subject.setSyncAttempt(true, 20158) - }, - ) { attempts: List -> - val expecteds = listOf( - SyncAttempt(0, 0, 0), - SyncAttempt(0, 1582, 1), - SyncAttempt(0, 19815, 2), - SyncAttempt(20158, 20158, 0), - ) - for (i in expecteds.indices) { - // Without print statements this will fail at times due to no attempts... - val attempt = attempts[i] - val expected = expecteds[i] - assertEquals(expected, attempt) - } - } - } - - @Test - fun clearSyncData() = runTest { - subject.setSyncAttempt(true, 20158) - subject.setSyncAttempt(false, 58354) - - val syncAttempt = subject.userData.first().syncAttempt - assertEquals(SyncAttempt(20158, 58354, 1), syncAttempt) - - subject.clearSyncData() - val clearedSyncAttempt = subject.userData.first().syncAttempt - assertEquals(SyncAttempt(0, 0, 0), clearedSyncAttempt) - } } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt index 77b1266c..01e2cca7 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/Background.kt @@ -51,14 +51,6 @@ annotation class ThemePreviews @ThemePreviews @Composable fun BackgroundDefault() { - CrisisCleanupTheme(disableDynamicTheming = true) { - CrisisCleanupBackground(Modifier.size(100.dp), content = {}) - } -} - -@ThemePreviews -@Composable -fun BackgroundDynamic() { CrisisCleanupTheme { CrisisCleanupBackground(Modifier.size(100.dp), content = {}) } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt index e6227d2e..12bb52b9 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Dimensions.kt @@ -29,6 +29,7 @@ data class Dimensions( val isLandscape: Boolean = false, val isPortrait: Boolean = true, val isListDetailWidth: Boolean = false, + val contentMaxWidth: Dp = 600.dp, val buttonSpinnerSize: Dp = 24.dp, ) { diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt index b3fdac43..c2cdae8e 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Theme.kt @@ -1,7 +1,5 @@ package com.crisiscleanup.core.designsystem.theme -import android.os.Build -import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.VisibleForTesting import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.material3.MaterialTheme @@ -116,32 +114,13 @@ val DarkColors = darkColorScheme( fun CrisisCleanupTheme( darkTheme: Boolean = isSystemInDarkTheme(), content: @Composable () -> Unit, -) = CrisisCleanupTheme( - darkTheme = darkTheme, - disableDynamicTheming = false, - content = content, -) - -/** - * App theme. This is an internal only version, to allow disabling dynamic theming - * in tests. - * - * @param darkTheme Whether the theme should use a dark color scheme (follows system by default). - * @param disableDynamicTheming If `true`, disables the use of dynamic theming, even when it is - * supported. - */ -@Composable -internal fun CrisisCleanupTheme( - darkTheme: Boolean = isSystemInDarkTheme(), - disableDynamicTheming: Boolean, - content: @Composable () -> Unit, ) { // Color scheme - val colorScheme = SingleColors + val colorScheme = if (darkTheme) DarkColors else LightColors // Background theme val defaultBackgroundTheme = BackgroundTheme( - color = colorScheme.background, + color = colorScheme.surface, ) val configuration = LocalConfiguration.current @@ -159,12 +138,9 @@ internal fun CrisisCleanupTheme( LocalFontStyles provides CrisisCleanupTypographyDefault, ) { MaterialTheme( - colorScheme = colorScheme, + colorScheme = SingleColors, typography = AppTypography, content = content, ) } } - -@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.S) -private fun supportsDynamicTheming() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt index f437ec5c..829a1fc4 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AccountData.kt @@ -52,6 +52,13 @@ data class AccountData( val isAccessTokenExpired: Boolean get() = tokenExpiry <= Clock.System.now().minus(1.minutes) + + fun filterApproved(incidents: List) = + if (isCrisisCleanupAdmin) { + incidents + } else { + incidents.filter { approvedIncidents.contains(it.id) } + } } val emptyOrgData = OrgData(0, "") diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt index 19f768b6..3861f486 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt @@ -2,8 +2,12 @@ package com.crisiscleanup.core.model.data import kotlinx.datetime.Instant +interface IncidentIdProvider { + val id: Long +} + data class Incident( - val id: Long, + override val id: Long, val name: String, val shortName: String, val caseLabel: String, @@ -15,7 +19,7 @@ data class Incident( val disaster: Disaster = disasterFromLiteral(disasterLiteral), val displayLabel: String = if (caseLabel.isBlank()) name else "$caseLabel: $name", val startAt: Instant? = null, -) { +) : IncidentIdProvider { val formFieldLookup: Map by lazy { formFields.associateBy { it.fieldKey } } @@ -75,12 +79,12 @@ data class IncidentFormField( } data class IncidentIdNameType( - val id: Long, + override val id: Long, val name: String, val shortName: String, val disasterLiteral: String, val disaster: Disaster = disasterFromLiteral(disasterLiteral), -) +) : IncidentIdProvider val Incident.idNameType: IncidentIdNameType get() = IncidentIdNameType( diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt index 0f11f94d..92d47b63 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentWorksitesCachePreferences.kt @@ -1,5 +1,7 @@ package com.crisiscleanup.core.model.data +import kotlinx.datetime.Instant + const val BOUNDED_REGION_RADIUS_MILES_DEFAULT = 30.0 data class BoundedRegionParameters( @@ -15,6 +17,7 @@ data class IncidentWorksitesCachePreferences( val isPaused: Boolean, val isRegionBounded: Boolean, val boundedRegionParameters: BoundedRegionParameters, + val lastReconciled: Instant, ) { val isAutoCache by lazy { !(isPaused || isRegionBounded) @@ -33,4 +36,5 @@ val InitialIncidentWorksitesCachePreferences = IncidentWorksitesCachePreferences isPaused = false, isRegionBounded = false, boundedRegionParameters = BoundedRegionParametersNone, + lastReconciled = Instant.fromEpochSeconds(0), ) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index 68ca232d..37e82643 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -8,8 +8,6 @@ data class UserData( val darkThemeConfig: DarkThemeConfig, val shouldHideOnboarding: Boolean, - val syncAttempt: SyncAttempt, - val selectedIncidentId: Long, val languageKey: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index 446aec72..c6a8b5ba 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -20,6 +20,7 @@ import com.crisiscleanup.core.network.model.NetworkTeamResult import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkWorkTypeRequest import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult +import com.crisiscleanup.core.network.model.NetworkWorksiteChange import com.crisiscleanup.core.network.model.NetworkWorksiteCoreData import com.crisiscleanup.core.network.model.NetworkWorksiteFull import com.crisiscleanup.core.network.model.NetworkWorksiteLocationSearch @@ -101,28 +102,33 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkWorksitesPageResult suspend fun getWorksitesPageBefore( incidentId: Long, pageCount: Int, updatedBefore: Instant, + offset: Int, ): NetworkWorksitesPageResult = getWorksitesPageUpdatedAt( incidentId, pageCount, updatedBefore, true, + offset = offset, ) suspend fun getWorksitesPageAfter( incidentId: Long, pageCount: Int, updatedAfter: Instant, + offset: Int, ): NetworkWorksitesPageResult = getWorksitesPageUpdatedAt( incidentId, pageCount, updatedAfter, false, + offset = offset, ) suspend fun getWorksitesFlagsFormDataPage( @@ -130,28 +136,33 @@ interface CrisisCleanupNetworkDataSource { pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkFlagsFormDataResult suspend fun getWorksitesFlagsFormDataPageBefore( incidentId: Long, pageCount: Int, updatedBefore: Instant, + offset: Int, ) = getWorksitesFlagsFormDataPage( incidentId, pageCount, updatedBefore, true, + offset = offset, ) suspend fun getWorksitesFlagsFormDataPageAfter( incidentId: Long, pageCount: Int, updatedAfter: Instant, + offset: Int, ) = getWorksitesFlagsFormDataPage( incidentId, pageCount, updatedAfter, false, + offset = offset, ) suspend fun getWorksitesFlagsFormData( @@ -213,4 +224,6 @@ interface CrisisCleanupNetworkDataSource { limit: Int = 0, offset: Int = 0, ): NetworkTeamResult + + suspend fun getWorksiteChanges(after: Instant): List } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt new file mode 100644 index 00000000..00feb3f5 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksiteChange.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkWorksiteChangesResult( + val errors: List? = null, + val error: String? = null, + val changes: List? = null, +) + +@Serializable +data class NetworkWorksiteChange( + @SerialName("incident_id") + val incidentId: Long, + @SerialName("worksite_id") + val worksiteId: Long, + @SerialName("invalidated_at") + val invalidatedAt: Instant?, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 5de64191..0eb4b4eb 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -22,6 +22,8 @@ import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkUsersResult import com.crisiscleanup.core.network.model.NetworkWorkTypeRequestResult import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult +import com.crisiscleanup.core.network.model.NetworkWorksiteChange +import com.crisiscleanup.core.network.model.NetworkWorksiteChangesResult import com.crisiscleanup.core.network.model.NetworkWorksiteLocationSearchResult import com.crisiscleanup.core.network.model.NetworkWorksitesCoreDataResult import com.crisiscleanup.core.network.model.NetworkWorksitesFullResult @@ -221,6 +223,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") pageCount: Int, + @Query("offset") + offset: Int, @Query("updated_at__lt") updatedBefore: Instant, @Query("sort") @@ -234,6 +238,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") pageCount: Int, + @Query("offset") + offset: Int, @Query("updated_at__gt") updatedAfter: Instant, @Query("sort") @@ -315,6 +321,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") limit: Int, + @Query("offset") + offset: Int, @Query("updated_at__lt") updatedAtBefore: Instant, @Query("sort") @@ -328,6 +336,8 @@ private interface DataSourceApi { incidentId: Long, @Query("limit") limit: Int, + @Query("offset") + offset: Int, @Query("updated_at__gt") updatedAfter: Instant, @Query("sort") @@ -366,6 +376,14 @@ private interface DataSourceApi { @Query("offset") offset: Int, ): NetworkTeamResult + + @TokenAuthenticationHeader + @WrapResponseHeader("changes") + @GET("worksites_changes") + suspend fun getWorksiteChanges( + @Query("since") + after: Instant, + ): NetworkWorksiteChangesResult } private val worksiteCoreDataFields = listOf( @@ -520,11 +538,13 @@ class DataApiClient @Inject constructor( pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkWorksitesPageResult { val result = if (isPagingBackwards) { networkApi.getWorksitesPageUpdatedBefore( incidentId, pageCount, + offset = offset, updatedAt, "-updated_at", ) @@ -532,6 +552,7 @@ class DataApiClient @Inject constructor( networkApi.getWorksitesPageUpdatedAfter( incidentId, pageCount, + offset = offset, updatedAt, "updated_at", ) @@ -546,11 +567,13 @@ class DataApiClient @Inject constructor( pageCount: Int, updatedAt: Instant, isPagingBackwards: Boolean, + offset: Int, ): NetworkFlagsFormDataResult { val result = if (isPagingBackwards) { networkApi.getWorksitesFlagsFormDataBefore( incidentId, pageCount, + offset = offset, updatedAt, "-updated_at", ) @@ -558,6 +581,7 @@ class DataApiClient @Inject constructor( networkApi.getWorksitesFlagsFormDataAfter( incidentId, pageCount, + offset = offset, updatedAt, "updated_at", ) @@ -688,4 +712,13 @@ class DataApiClient @Inject constructor( override suspend fun getTeams(incidentId: Long?, limit: Int, offset: Int) = networkApi.getTeams(incidentId, limit, offset) + + override suspend fun getWorksiteChanges(after: Instant): List { + val result = networkApi.getWorksiteChanges(after) + result.errors?.tryThrowException() + result.error?.let { errorMessage -> + throw Exception(errorMessage) + } + return result.changes ?: emptyList() + } } diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt similarity index 95% rename from core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt rename to core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt index 3d132419..d98de0e6 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkProfileTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkAccountProfileTest.kt @@ -5,10 +5,10 @@ import org.junit.Test import kotlin.test.assertEquals import kotlin.test.assertNull -class NetworkProfileTest { +class NetworkAccountProfileTest { @Test - fun profileNoAuthResult() { - val account = TestUtil.decodeResource("/getProfileAuth.json") + fun profileAuthResult() { + val account = TestUtil.decodeResource("/getAccountProfileAuth.json") assertEquals(setOf(291L), account.approvedIncidents) assertEquals(true, account.hasAcceptedTerms) @@ -44,8 +44,8 @@ class NetworkProfileTest { } @Test - fun profileAuthResult() { - val account = TestUtil.decodeResource("/getProfileNoAuth.json", true) + fun profileNoAuthResult() { + val account = TestUtil.decodeResource("/getAccountProfileNoAuth.json", true) assertNull(account.approvedIncidents) assertNull(account.hasAcceptedTerms) diff --git a/core/network/src/test/resources/getProfileAuth.json b/core/network/src/test/resources/getAccountProfileAuth.json similarity index 100% rename from core/network/src/test/resources/getProfileAuth.json rename to core/network/src/test/resources/getAccountProfileAuth.json diff --git a/core/network/src/test/resources/getProfileNoAuth.json b/core/network/src/test/resources/getAccountProfileNoAuth.json similarity index 100% rename from core/network/src/test/resources/getProfileNoAuth.json rename to core/network/src/test/resources/getAccountProfileNoAuth.json diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt index 8bca4d05..1427e2b0 100644 --- a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt @@ -3,14 +3,12 @@ package com.crisiscleanup.core.testing.model import com.crisiscleanup.core.model.data.DarkThemeConfig import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.IncidentCoordinateBoundsNone -import com.crisiscleanup.core.model.data.SyncAttempt import com.crisiscleanup.core.model.data.UserData import com.crisiscleanup.core.model.data.WorksiteSortBy val UserDataNone = UserData( darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, shouldHideOnboarding = false, - syncAttempt = SyncAttempt(0, 0, 0), selectedIncidentId = EmptyIncident.id, languageKey = "", tableViewSortBy = WorksiteSortBy.None, diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index 33464b8d..79d1d4c4 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.common.subscribedReplay import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository import com.crisiscleanup.core.data.repository.ChangeOrganizationAction @@ -34,15 +35,18 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.net.URL import javax.inject.Inject -import com.crisiscleanup.core.common.combine as combineMore +import kotlin.time.Clock +import kotlin.time.ExperimentalTime @HiltViewModel class RequestOrgAccessViewModel @Inject constructor( @@ -58,11 +62,21 @@ class RequestOrgAccessViewModel @Inject constructor( @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Onboarding) private val logger: AppLogger, ) : ViewModel() { + companion object { + private var recentOrgTransfer = RecentOrgTransfer() + } + private val editorArgs = RequestOrgAccessArgs(savedStateHandle) private val invitationCode = editorArgs.inviteCode ?: "" val showEmailInput = editorArgs.showEmailInput ?: false + val isFromInvite = invitationCode.isNotBlank() + + @OptIn(ExperimentalTime::class) + val isRecentlyTransferred = recentOrgTransfer.isValidTransferCode(invitationCode) + val recentOrgTransferredTo = recentOrgTransfer.orgName + private val isFetchingInviteInfo = MutableStateFlow(!showEmailInput && invitationCode.isNotBlank()) @@ -88,8 +102,6 @@ class RequestOrgAccessViewModel @Inject constructor( TransferOrgOption.All, TransferOrgOption.DoNotTransfer, ) - var selectedOrgTransfer by mutableStateOf(TransferOrgOption.NotSelected) - private set var transferOrgErrorMessage by mutableStateOf("") private set val isTransferringOrg = MutableStateFlow(false) @@ -119,35 +131,41 @@ class RequestOrgAccessViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) - val isLoading = combineMore( - isPullingLanguageOptions, + private val isStateTransient = combine( isFetchingInviteInfo, isRequestingInvite, isTransferringOrg, - ) { b0, b1, b2, b3 -> - b0 || b1 || b2 || b3 - } + ::Triple, + ) + .map { (b0, b1, b2) -> b0 || b1 || b2 } + .distinctUntilChanged() + .shareIn( + scope = viewModelScope, + started = subscribedReplay(1), + replay = 1, + ) + + val isLoading = combine( + isPullingLanguageOptions, + isStateTransient, + ) { b0, b1 -> b0 || b1 } + .distinctUntilChanged() .stateIn( scope = viewModelScope, initialValue = false, started = SharingStarted.WhileSubscribed(), ) - var emailAddress by mutableStateOf("") - var emailAddressError by mutableStateOf("") - - val isEditable = combine( - isFetchingInviteInfo, - isRequestingInvite, - ::Pair, - ) - .map { (b0, b1) -> !(b0 || b1) } + val isEditable = isStateTransient.map(Boolean::not) .stateIn( scope = viewModelScope, initialValue = false, started = SharingStarted.WhileSubscribed(), ) + var emailAddress by mutableStateOf("") + var emailAddressError by mutableStateOf("") + init { requestedOrg .onEach { result -> @@ -317,12 +335,11 @@ class RequestOrgAccessViewModel @Inject constructor( } } - fun onChangeTransferOrgOption(option: TransferOrgOption) { - selectedOrgTransfer = option + fun onChangeTransferOrgOption() { transferOrgErrorMessage = "" } - fun onTransferOrg() { + fun onTransferOrg(selectedOrgTransfer: TransferOrgOption) { when (selectedOrgTransfer) { TransferOrgOption.DoNotTransfer -> isInviteRequested.value = true TransferOrgOption.Users, @@ -337,12 +354,6 @@ class RequestOrgAccessViewModel @Inject constructor( ChangeOrganizationAction.All } transferToOrg(action) - - val isAuthenticated = accountDataRepository.isAuthenticated.first() - if (isAuthenticated) { - clearInviteCode() - accountEventBus.onLogout() - } } finally { isTransferringOrg.value = false } @@ -354,9 +365,23 @@ class RequestOrgAccessViewModel @Inject constructor( } } + @OptIn(ExperimentalTime::class) private suspend fun transferToOrg(action: ChangeOrganizationAction) { - if (accountUpdateRepository.acceptOrganizationChange(action, invitationCode)) { + val isAuthenticated = accountDataRepository.isAuthenticated.first() + + val isTransferred = accountUpdateRepository.acceptOrganizationChange(action, invitationCode) + if (isTransferred) { isOrgTransferred.value = true + + if (isAuthenticated) { + recentOrgTransfer = RecentOrgTransfer( + invitationCode, + orgName = inviteDisplay.value?.inviteInfo?.orgName ?: "", + transferEpochSeconds = Clock.System.now().epochSeconds, + ) + + accountEventBus.onLogout() + } } else { logger.logException(Exception("User transfer to org failed.")) transferOrgErrorMessage = @@ -381,3 +406,20 @@ enum class TransferOrgOption(val translateKey: String) { All("invitationSignup.yes_transfer_me_and_cases"), DoNotTransfer("invitationSignup.no_transfer"), } + +/* + * Hack for edge case when authenticated user is transferred and logs out + * Navigation graph changes losing state for success screen + * Use for preserving data in this transition (between navigation graphs) + */ +private data class RecentOrgTransfer( + val code: String = "", + val orgName: String = "", + val transferEpochSeconds: Long = 0, +) { + @ExperimentalTime + fun isValidTransferCode(compare: String): Boolean { + return code == compare && + transferEpochSeconds + 60 > Clock.System.now().epochSeconds + } +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt index b02aadeb..70bd4163 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RequestOrgAccessScreen.kt @@ -2,10 +2,14 @@ package com.crisiscleanup.feature.authentication.ui import androidx.activity.compose.BackHandler import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape @@ -33,6 +37,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil.compose.AsyncImage import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.AnimatedBusyIndicator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton @@ -97,7 +102,13 @@ fun RequestOrgAccessRoute( onAction = clearStateOnBack, ) - if (inviteInfoErrorMessage.isNotBlank()) { + if (viewModel.isRecentlyTransferred) { + OnOrgTransferredView( + viewModel.recentOrgTransferredTo, + openForgotPassword = openForgotPassword, + openAuth = openAuth, + ) + } else if (inviteInfoErrorMessage.isNotBlank()) { Text( inviteInfoErrorMessage, listItemModifier.testTag("requestAccessInviteInfoError"), @@ -113,25 +124,12 @@ fun RequestOrgAccessRoute( onAction = clearStateOnBack, ) } else if (isOrgTransferred) { - val clearStateOpenAuth = remember(viewModel, openAuth) { - { - viewModel.clearInviteCode() - openAuth() - } - } - val clearStateForgotPassword = remember(viewModel, openForgotPassword) { - { - viewModel.clearInviteCode() - openForgotPassword() - } - } - val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() val orgName = displayInfo?.inviteInfo?.orgName ?: "" - OrgTransferSuccessView( + OnOrgTransferredView( orgName, - onForgotPassword = clearStateForgotPassword, - onLogin = clearStateOpenAuth, + openForgotPassword = openForgotPassword, + openAuth = openAuth, ) } else { RequestOrgUserInfoInputView( @@ -141,6 +139,33 @@ fun RequestOrgAccessRoute( } } +@Composable +private fun ColumnScope.OnOrgTransferredView( + orgName: String, + openForgotPassword: () -> Unit, + openAuth: () -> Unit, + viewModel: RequestOrgAccessViewModel = hiltViewModel(), +) { + val clearStateOpenAuth = remember(viewModel, openAuth) { + { + viewModel.clearInviteCode() + openAuth() + } + } + val clearStateForgotPassword = remember(viewModel, openForgotPassword) { + { + viewModel.clearInviteCode() + openForgotPassword() + } + } + + OrgTransferSuccessView( + orgName, + onForgotPassword = clearStateForgotPassword, + onLogin = clearStateOpenAuth, + ) +} + @Composable private fun RequestOrgUserInfoInputView( onBack: () -> Unit, @@ -149,7 +174,7 @@ private fun RequestOrgUserInfoInputView( val displayInfo by viewModel.inviteDisplay.collectAsStateWithLifecycle() val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() - if (displayInfo == null) { + if (viewModel.isFromInvite && displayInfo == null) { Box(Modifier.fillMaxSize()) { BusyIndicatorFloatingTopCenter(true) } @@ -184,7 +209,7 @@ private fun RequestOrgUserInfoInputView( scrollState, isEditable = isEditable, isLoading = isLoading, - displayInfo!!, + displayInfo, ) } } @@ -243,6 +268,8 @@ private fun InviteExistingUserContent( val t = LocalAppTranslator.current val translationCount by t.translationCount.collectAsStateWithLifecycle() + var selectedOrgTransfer by remember { mutableStateOf(TransferOrgOption.NotSelected) } + val inviteInfo = displayInfo.inviteInfo val transferInstructions = t("invitationSignup.inviting_to_transfer_confirm") .replace("{user}", inviteInfo.displayName) @@ -254,13 +281,15 @@ private fun InviteExistingUserContent( listItemModifier, ) - val selectedOption = viewModel.selectedOrgTransfer for (option in viewModel.transferOrgOptions) { CrisisCleanupRadioButton( listItemModifier, - option == selectedOption, + option == selectedOrgTransfer, text = t(option.translateKey), - onSelect = { viewModel.onChangeTransferOrgOption(option) }, + onSelect = { + selectedOrgTransfer = option + viewModel.onChangeTransferOrgOption() + }, enabled = isEditable, ) } @@ -279,21 +308,21 @@ private fun InviteExistingUserContent( } BusyButton( fillWidthPadded.testTag("transferOrgSubmitAction"), - enabled = isEditable && selectedOption != TransferOrgOption.NotSelected, + enabled = isEditable && selectedOrgTransfer != TransferOrgOption.NotSelected, text = transferText, indicateBusy = isLoading, onClick = { - if (selectedOption == TransferOrgOption.DoNotTransfer) { + if (selectedOrgTransfer == TransferOrgOption.DoNotTransfer) { onBack() } else { - viewModel.onTransferOrg() + viewModel.onTransferOrg(selectedOrgTransfer) } }, ) } @Composable -private fun OrgTransferSuccessView( +private fun ColumnScope.OrgTransferSuccessView( orgName: String, onForgotPassword: () -> Unit, onLogin: () -> Unit, @@ -312,6 +341,8 @@ private fun OrgTransferSuccessView( listItemModifier, ) + Spacer(Modifier.weight(1f)) + CrisisCleanupOutlinedButton( modifier = listItemModifier .actionHeight(), @@ -334,7 +365,7 @@ private fun InviteNewUserContent( scrollState: ScrollState, isEditable: Boolean, isLoading: Boolean, - displayInfo: InviteDisplayInfo, + displayInfo: InviteDisplayInfo?, viewModel: RequestOrgAccessViewModel = hiltViewModel(), ) { val t = LocalAppTranslator.current @@ -374,17 +405,29 @@ private fun InviteNewUserContent( onNext = clearErrorVisuals, ) } else { - val info = displayInfo - val avatarUrl = displayInfo.avatarUrl - if (avatarUrl != null && - info.displayName.isNotBlank() && - info.inviteMessage.isNotBlank() - ) { - InviterAvatar( - avatarUrl, - displayName = info.displayName, - inviteMessage = info.inviteMessage, - ) + if (displayInfo == null) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AnimatedBusyIndicator( + isLoading, + padding = 16.dp, + ) + } + } else { + val info = displayInfo + val avatarUrl = displayInfo.avatarUrl + if (avatarUrl != null && + info.displayName.isNotBlank() && + info.inviteMessage.isNotBlank() + ) { + InviterAvatar( + avatarUrl, + displayName = info.displayName, + inviteMessage = info.inviteMessage, + ) + } } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt index ce8f0e08..537d671a 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/ResetPasswordScreen.kt @@ -74,7 +74,7 @@ fun ResetPasswordRoute( TopAppBarBackAction( title = t("actions.reset_password"), onAction = clearStateOnBack, - modifier = Modifier.testTag("passwordRecoverBackBtn"), + modifier = Modifier, ) if (isPasswordReset) { @@ -191,7 +191,7 @@ private fun ResetPasswordView( modifier = fillWidthPadded.testTag("resetPasswordBtn"), onClick = viewModel::onResetPassword, enabled = isEditable, - text = translator("actions.reset"), + text = translator("actions.reset_password"), indicateBusy = isBusy, ) } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt index 20a6abaa..edb161ac 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt @@ -145,12 +145,19 @@ private fun AuthenticatedScreen( val t = LocalAppTranslator.current Text( - modifier = fillWidthPadded.testTag("authedAccountInfoText"), + modifier = fillWidthPadded.testTag("accountInfoText"), text = t("info.account_is") .replace("{full_name}", accountData.fullName) .replace("{email_address}", accountData.emailAddress), ) + if (accountData.org.name.isNotBlank()) { + Text( + modifier = fillWidthPadded.testTag("organizationText"), + text = t(accountData.org.name), + ) + } + val authErrorMessage by viewModel.errorMessage ConditionalErrorMessage(authErrorMessage, "authenticated") diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt index c21ae94a..6ec55425 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt @@ -415,6 +415,7 @@ internal class CaseEditorDataLoader( if (worksite.id > 0 && (networkId > 0 || localWorksite.localChanges.isLocalModified) ) { + // TODO Delete worksite if is not exists on backend and notify user Worksite no longer exists isRefreshingWorksite.value = true if (worksiteChangeRepository.trySyncWorksite(worksite.id) && networkId > 0 diff --git a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt index 3a64d580..e5085207 100644 --- a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt +++ b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt @@ -170,6 +170,7 @@ private fun IncidentWorksitesCacheScreen( IncidentCacheStage.WorksitesAdditional -> t("appCache.syncing_additional_case_data") IncidentCacheStage.ActiveIncident -> t("appCache.syncing_active_incident") IncidentCacheStage.ActiveIncidentOrganization -> t("appCache.syncing_organizations_in_incident") + IncidentCacheStage.WorksitesChangedIncident -> t("~~Syncing Cases with changed Incidents...") IncidentCacheStage.End -> t("appCache.sync_finished") } Text(syncStageMessage) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index fdfd1777..697313d8 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -22,7 +22,6 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Badge -import androidx.compose.material3.BadgedBox import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -62,6 +61,7 @@ import com.crisiscleanup.core.designsystem.component.OpenSettingsDialog import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.component.actionRoundCornerShape import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.LocalDimensions import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.cardContainerColor import com.crisiscleanup.core.designsystem.theme.listItemBottomPadding @@ -196,7 +196,7 @@ private fun MenuScreen( val incidentDataCacheMetrics by viewModel.incidentDataCacheMetrics.collectAsStateWithLifecycle() val hasSpeedNotAdaptive = incidentDataCacheMetrics.hasSpeedNotAdaptive - Column { + Column(horizontalAlignment = Alignment.CenterHorizontally) { AppTopBar( incidentDropdownModifier = incidentDropdownModifier, accountToggleModifier = accountToggleModifier @@ -251,7 +251,8 @@ private fun MenuScreen( } LazyColumn( - Modifier.weight(1f), + Modifier.weight(1f) + .sizeIn(maxWidth = LocalDimensions.current.contentMaxWidth), state = lazyListState, ) { hotlineItems( @@ -729,33 +730,24 @@ private fun AppUpdateView() { horizontalArrangement = listItemSpacedBy, verticalAlignment = Alignment.CenterVertically, ) { - var badgeOffsetX by remember { mutableStateOf(0.dp) } - val localDensity = LocalDensity.current - BadgedBox( - badge = { - Badge( - Modifier - .size(20.dp) - .offset(x = badgeOffsetX), - containerColor = primaryOrangeColor, - ) { - // TODO: Match content color in menu badge - Icon( - imageVector = CrisisCleanupIcons.AppUpdateAvailable, - contentDescription = null, - ) - } - }, + Box( Modifier.weight(1f), ) { Text( t("~~A new version of the app is available"), - Modifier.onGloballyPositioned { - badgeOffsetX = with(localDensity) { - -it.size.width.div(2).toDp() - } - }, + Modifier.align(Alignment.CenterStart), ) + Badge( + Modifier.size(20.dp) + .offset(x = (-10).dp, y = (-10).dp), + containerColor = primaryOrangeColor, + ) { + Icon( + imageVector = CrisisCleanupIcons.AppUpdateAvailable, + contentDescription = null, + tint = Color.White, + ) + } } val context = LocalContext.current