diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 68dfcf679..b44ef03fd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 250 + val buildVersion = 252 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 8688218ac..b813f138d 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -166,6 +166,12 @@ class MainActivity : ComponentActivity() { logUnprocessedExternalUri(it) } } + + if (savedInstanceState == null) { + lifecycleScope.launch { + appMetricsRepository.setAppOpen() + } + } } override fun onNewIntent(intent: Intent) { @@ -185,7 +191,7 @@ class MainActivity : ComponentActivity() { super.onStart() syncPuller.appPullIncidentData() visualAlertManager.setNonProductionAppAlert(true) - viewModel.onAppOpen() + viewModel.onAppFocus() endOfLifeRepository.saveEndOfLifeData() appMetricsRepository.saveAppSupportInfo() diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 8b48b8dfa..a31437f60 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -7,7 +7,6 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.AppSettingsProvider -import com.crisiscleanup.core.common.AppVersionProvider import com.crisiscleanup.core.common.CrisisCleanupTutorialDirectors.Menu import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.NetworkMonitor @@ -33,7 +32,6 @@ import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository import com.crisiscleanup.core.data.repository.ShareLocationRepository import com.crisiscleanup.core.model.data.AccountData import com.crisiscleanup.core.model.data.AppMetricsData -import com.crisiscleanup.core.model.data.AppOpenInstant import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.MinSupportedAppVersion import com.crisiscleanup.core.model.data.UserData @@ -54,10 +52,7 @@ import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch -import kotlinx.datetime.Clock -import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject -import kotlin.time.Duration.Companion.hours @HiltViewModel class MainActivityViewModel @Inject constructor( @@ -72,7 +67,6 @@ class MainActivityViewModel @Inject constructor( val tutorialViewTracker: TutorialViewTracker, val translator: KeyResourceTranslator, private val syncPuller: SyncPuller, - private val appVersionProvider: AppVersionProvider, appSettingsProvider: AppSettingsProvider, private val appEnv: AppEnv, firebaseAnalytics: FirebaseAnalytics, @@ -83,25 +77,15 @@ class MainActivityViewModel @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : ViewModel() { - /** - * Previous app open - * - * Sets only once every app session. - */ - private val initialAppOpen = AtomicReference(null) - val viewState = combine( appPreferencesRepository.userPreferences, appMetricsRepository.metrics.distinctUntilChanged(), ::Pair, ) .map { (preferences, metrics) -> - if (initialAppOpen.compareAndSet(null, metrics.appOpen)) { - onAppOpen() - } - MainActivityViewState.Success(preferences, metrics) - }.stateIn( + } + .stateIn( scope = viewModelScope, initialValue = MainActivityViewState.Loading, started = SharingStarted.WhileSubscribed(5_000), @@ -258,16 +242,7 @@ class MainActivityViewModel @Inject constructor( ) } - fun onAppOpen() { - initialAppOpen.get()?.let { - viewModelScope.launch { - val previousOpen = appMetricsRepository.metrics.first().appOpen - if (Clock.System.now() - previousOpen.date > 1.hours) { - appMetricsRepository.setAppOpen(appVersionProvider.versionCode) - } - } - } - + fun onAppFocus() { viewModelScope.launch(ioDispatcher) { shareLocationWithOrganization() } diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index b1954784a..cf0d1f126 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -189,6 +189,7 @@ fun CrisisCleanupNavHost( modifier = modifier, ) { casesGraph( + navController, nestedGraphs = { casesSearchScreen(searchCasesOnBack, viewCase) casesFilterScreen(onBack) @@ -217,6 +218,7 @@ fun CrisisCleanupNavHost( openViewTeam = navController::navigateToViewTeam, ) menuScreen( + navController, openAuthentication = openAuthentication, openIncidentCache = openIncidentCache, openLists = openLists, diff --git a/core/appnav/build.gradle.kts b/core/appnav/build.gradle.kts index c9d083eb7..da11c456b 100644 --- a/core/appnav/build.gradle.kts +++ b/core/appnav/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.nowinandroid.android.library) + alias(libs.plugins.nowinandroid.android.library.compose) alias(libs.plugins.nowinandroid.android.library.jacoco) } @@ -9,6 +10,8 @@ android { dependencies { implementation(projects.core.common) + + implementation(libs.androidx.hilt.navigation.compose) implementation(libs.androidx.lifecycle.viewModelCompose) implementation(libs.androidx.navigation.compose) } \ No newline at end of file diff --git a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt index 05b0b1f30..18edf39f5 100644 --- a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt +++ b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/CommonNavigation.kt @@ -1,5 +1,10 @@ package com.crisiscleanup.core.appnav +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import com.crisiscleanup.core.appnav.RouteConstant.VIEW_CASE_ROUTE @@ -10,3 +15,15 @@ fun NavController.navigateToExistingCase(incidentId: Long, worksiteId: Long) { // Must match composable route signature this.navigate("${VIEW_CASE_ROUTE}?$INCIDENT_ID_ARG=$incidentId&$WORKSITE_ID_ARG=$worksiteId") } + +// https://medium.com/@mahbooberezaee68/retrieving-viewmodels-within-navigation-graphs-jetpack-compose-4eb5a1293d25 +@Composable +inline fun NavBackStackEntry.sharedViewModel( + navController: NavController, + navGraphRoute: String, +): T { + val parentEntry = remember(this) { + navController.getBackStackEntry(navGraphRoute) + } + return hiltViewModel(parentEntry) +} diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/KeyTranslator.kt b/core/common/src/main/java/com/crisiscleanup/core/common/KeyTranslator.kt index 6afb5453b..dfb02a5a1 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/KeyTranslator.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/KeyTranslator.kt @@ -29,9 +29,7 @@ class AndroidResourceTranslator @Inject constructor( override fun translate(phraseKey: String, @StringRes fallbackResId: Int) = keyTranslator.translate(phraseKey) ?: ( if (fallbackResId != 0) { - context.getString( - fallbackResId, - ) + context.getString(fallbackResId) } else { phraseKey } diff --git a/core/commonassets/src/main/java/com/crisiscleanup/core/commonassets/DisasterIcon.kt b/core/commonassets/src/main/java/com/crisiscleanup/core/commonassets/DisasterIcon.kt index 15dec449d..d2c913967 100644 --- a/core/commonassets/src/main/java/com/crisiscleanup/core/commonassets/DisasterIcon.kt +++ b/core/commonassets/src/main/java/com/crisiscleanup/core/commonassets/DisasterIcon.kt @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.theme.disabledAlpha import com.crisiscleanup.core.designsystem.theme.incidentDisasterContainerColor import com.crisiscleanup.core.designsystem.theme.incidentDisasterContentColor import com.crisiscleanup.core.model.data.Disaster @@ -41,12 +42,17 @@ fun getDisasterIcon(disaster: Disaster) = statusIcons[disaster] ?: R.drawable.ic fun DisasterIcon( @DrawableRes disasterResId: Int, incidentName: String, + enabled: Boolean, modifier: Modifier = Modifier, ) { Surface( modifier = modifier, shape = CircleShape, - color = incidentDisasterContainerColor, + color = if (enabled) { + incidentDisasterContainerColor + } else { + incidentDisasterContainerColor.disabledAlpha() + }, contentColor = incidentDisasterContentColor, ) { Icon( diff --git a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentDropdownSelect.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentDropdownSelect.kt index 431c19cb4..3d1d2cd8c 100644 --- a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentDropdownSelect.kt +++ b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentDropdownSelect.kt @@ -43,7 +43,7 @@ fun IncidentDropdownSelect( CompositionLocalProvider( LocalContentColor provides if (enabled) contentColor else contentColor.disabledAlpha(), ) { - DisasterIcon(disasterIconResId, title) + DisasterIcon(disasterIconResId, title, enabled) TruncatedAppBarText( title = title, modifier = Modifier.padding(start = 8.dp), diff --git a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt index e7ee2d154..870546e9f 100644 --- a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt +++ b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt @@ -41,7 +41,7 @@ fun IncidentHeaderView( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = listItemSpacedBy, ) { - DisasterIcon(disasterResId, incidentName) + DisasterIcon(disasterResId, incidentName, true) Text( incidentName, Modifier diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkWorksite.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkWorksite.kt index a41518dad..f9891d996 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkWorksite.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkWorksite.kt @@ -26,7 +26,7 @@ fun NetworkWorksiteFull.asEntity() = WorksiteEntity( caseNumber = caseNumber, caseNumberOrder = parseCaseNumberOrder(caseNumber), city = city, - county = county, + county = county ?: "", email = email, favoriteId = favorite?.id, keyWorkTypeType = newestKeyWorkType?.workType ?: "", @@ -56,7 +56,7 @@ fun NetworkWorksiteCoreData.asEntity() = WorksiteEntity( caseNumber = caseNumber, caseNumberOrder = parseCaseNumberOrder(caseNumber), city = city, - county = county, + county = county ?: "", email = email, favoriteId = favorite?.id, keyWorkTypeType = "", @@ -84,7 +84,7 @@ fun NetworkWorksiteShort.asEntity() = WorksiteEntity( caseNumber = caseNumber, caseNumberOrder = parseCaseNumberOrder(caseNumber), city = city, - county = county, + county = county ?: "", createdAt = createdAt, favoriteId = favoriteId, keyWorkTypeType = newestKeyWorkType?.workType ?: "", @@ -115,7 +115,7 @@ fun NetworkWorksitePage.asEntity() = WorksiteEntity( caseNumber = caseNumber, caseNumberOrder = parseCaseNumberOrder(caseNumber), city = city, - county = county, + county = county ?: "", createdAt = createdAt, favoriteId = favoriteId, keyWorkTypeType = newestKeyWorkType?.workType ?: "", diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppMetricsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppMetricsRepository.kt index 1e1c42472..3f418b37a 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppMetricsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppMetricsRepository.kt @@ -28,7 +28,6 @@ interface LocalAppMetricsRepository { suspend fun setEarlybirdEnd(end: BuildEndOfLife) suspend fun setAppOpen( - appVersion: Long, timestamp: Instant = Clock.System.now(), ) @@ -51,11 +50,8 @@ class AppMetricsRepository @Inject constructor( dataSource.setEarlybirdEnd(end) } - override suspend fun setAppOpen( - appVersion: Long, - timestamp: Instant, - ) { - dataSource.setAppOpen(appVersion, timestamp) + override suspend fun setAppOpen(timestamp: Instant) { + dataSource.setAppOpen(timestamp) } override fun saveAppSupportInfo() { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt index 8f02f9163..7c271f893 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt @@ -77,4 +77,8 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setTeamMapBounds(bounds: IncidentCoordinateBounds) { preferencesDataSource.saveTeamMapBounds(bounds) } + + override suspend fun setWorkScreenView(isTableView: Boolean) { + preferencesDataSource.saveWorkScreenView(isTableView) + } } 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 0782dcec2..bb863edb0 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 @@ -394,6 +394,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( // TODO If not preloaded and times out try caching around coordinates val shortResult = cacheWorksitesCore( incidentId, + syncPlan.timestamp, isPaused, syncStats, worksitesCoreStatsUpdater, @@ -406,6 +407,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( ) { cacheWorksitesCore( incidentId, + syncPlan.timestamp, false, syncStats, worksitesCoreStatsUpdater, @@ -465,6 +467,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( val additionalResult = cacheAdditionalWorksiteData( incidentId, + syncPlan.timestamp, isPaused, syncStats, worksitesAdditionalStatsUpdater, @@ -476,6 +479,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( ) { cacheAdditionalWorksiteData( incidentId, + syncPlan.timestamp, false, syncStats, worksitesAdditionalStatsUpdater, @@ -845,6 +849,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( private suspend fun cacheWorksitesCore( incidentId: Long, + syncStart: Instant, isPaused: Boolean, syncParameters: IncidentDataSyncParameters, statsUpdater: IncidentDataPullStatsUpdater, @@ -888,6 +893,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( val afterResult = cacheWorksitesAfter( IncidentCacheStage.WorksitesCore, incidentId, + syncStart, isPaused, unmeteredDataCountThreshold = 9000, timeMarkers, @@ -1016,6 +1022,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( private suspend fun cacheWorksitesAfter( stage: IncidentCacheStage, incidentId: Long, + syncStart: Instant, isPaused: Boolean, unmeteredDataCountThreshold: Int, timeMarkers: IncidentDataSyncParameters.SyncTimeMarker, @@ -1054,7 +1061,17 @@ class IncidentWorksitesCacheRepository @Inject constructor( result.data ?: emptyList() } + fun updateUpdatedAfter(timestamp: Instant) { + if (stage == IncidentCacheStage.WorksitesCore) { + syncParameterDao.updateUpdatedAfter(incidentId, timestamp) + } else { + syncParameterDao.updateAdditionalUpdatedAfter(incidentId, timestamp) + } + } + if (networkData.isEmpty()) { + updateUpdatedAfter(syncStart) + log("Cached $savedCount/$initialCount after. No Cases after $afterTimeMarker") } else { downloadSpeedTracker.averageSpeed()?.let { @@ -1082,11 +1099,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( queryCount = (queryCount * 2).coerceAtMost(maxQueryCount) afterTimeMarker = networkData.last().updatedAt - if (stage == IncidentCacheStage.WorksitesCore) { - syncParameterDao.updateUpdatedAfter(incidentId, afterTimeMarker) - } else { - syncParameterDao.updateAdditionalUpdatedAfter(incidentId, afterTimeMarker) - } + updateUpdatedAfter(afterTimeMarker) log("Cached ${deduplicateWorksites.size} ($savedCount/$initialCount) after, up to $afterTimeMarker") } @@ -1152,6 +1165,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( private suspend fun cacheAdditionalWorksiteData( incidentId: Long, + syncStart: Instant, isPaused: Boolean, syncParameters: IncidentDataSyncParameters, statsUpdater: IncidentDataPullStatsUpdater, @@ -1199,6 +1213,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( val afterResult = cacheWorksitesAfter( IncidentCacheStage.WorksitesAdditional, incidentId, + syncStart, isPaused, unmeteredDataCountThreshold = 3000, timeMarkers, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt index c69c8af4a..80a628bd9 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt @@ -38,4 +38,6 @@ interface LocalAppPreferencesRepository { suspend fun setCasesMapBounds(bounds: IncidentCoordinateBounds) suspend fun setTeamMapBounds(bounds: IncidentCoordinateBounds) + + suspend fun setWorkScreenView(isTableView: Boolean) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/SearchWorksiteRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/SearchWorksiteRepository.kt index 964653665..c52cc11fc 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/SearchWorksiteRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/SearchWorksiteRepository.kt @@ -108,7 +108,7 @@ class MemoryCacheSearchWorksitesRepository @Inject constructor( city, state, postalCode ?: "", - county, + county ?: "", caseNumber, workType, ) diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt index 4ad3d8fdd..4262b94fc 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt @@ -8,13 +8,14 @@ import com.crisiscleanup.core.database.model.IncidentLocationEntity import com.crisiscleanup.core.database.model.PopulatedIncident import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.testing.util.nowTruncateMillis import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest -import kotlinx.datetime.Clock import kotlinx.datetime.Instant import org.junit.Before import org.junit.Test import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds class IncidentDaoTest { private lateinit var db: TestCrisisCleanupDatabase @@ -120,13 +121,14 @@ class IncidentDaoTest { */ @Test fun saveIncidentPhoneNumbers() = runTest { - val nowInstant = Clock.System.now() + val nowInstant = nowTruncateMillis fun phoneIncidentEntity( id: Long, phoneNumber: String?, + startAt: Instant = nowInstant, ) = IncidentEntity( id = id, - startAt = nowInstant, + startAt = startAt, activePhoneNumber = phoneNumber, name = "", shortName = "", @@ -144,20 +146,25 @@ class IncidentDaoTest { incidentDao.upsertIncidents(existingIncidents) // Save + val updatedInstant = nowTruncateMillis.plus(6.seconds) incidentDao.upsertIncidents( listOf( // Not defined to multiple numbers - phoneIncidentEntity(1, "phone,changed"), + phoneIncidentEntity(1, "phone,changed", updatedInstant), // Multiple numbers to not defined - phoneIncidentEntity(4, null), + phoneIncidentEntity(4, null, updatedInstant), // New incidents - phoneIncidentEntity(5, "new-incident"), - phoneIncidentEntity(6, "phone-1, phone-2"), + phoneIncidentEntity(5, "new-incident", updatedInstant), + phoneIncidentEntity(6, "phone-1, phone-2", updatedInstant), ), ) // Assert - fun expectedIncident(id: Long, phoneNumbers: List) = Incident( + fun expectedIncident( + id: Long, + phoneNumbers: List, + startAt: Instant = updatedInstant, + ) = Incident( id, "", "", @@ -166,21 +173,21 @@ class IncidentDaoTest { phoneNumbers, emptyList(), false, + startAt = startAt, ) val expecteds = listOf( expectedIncident(1, listOf("phone", "changed")), - expectedIncident(2, listOf("one", "two")), - expectedIncident(3, listOf()), + expectedIncident(2, listOf("one", "two"), startAt = nowInstant), + expectedIncident(3, listOf(), startAt = nowInstant), expectedIncident(4, listOf()), expectedIncident(5, listOf("new-incident")), expectedIncident(6, listOf("phone-1", "phone-2")), - ).reversed() - val savedIncidents = - incidentDao.streamIncidents().first().map(PopulatedIncident::asExternalModel) - for (i in expecteds.indices) { - assertEquals(expecteds[i], savedIncidents[i], "$i") - } + ) + val savedIncidents = incidentDao.streamIncidents().first() + .map(PopulatedIncident::asExternalModel) + .sortedBy(Incident::id) + assertEquals(expecteds, savedIncidents) } /** 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 b91fe24c8..8c167d2d9 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 @@ -15,4 +15,6 @@ message AppMetrics { int64 productionApiSwitchVersion = 4; AppMinUseProto minBuildSupport = 5; + + int64 appInstallVersion = 6; } 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 ed1bd4e2a..ffb17b679 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 @@ -40,4 +40,6 @@ message UserPreferences { IncidentMapBoundsProto cases_map_bounds = 13; IncidentMapBoundsProto team_map_bounds = 14; + + bool is_work_screen_table_view = 15; } diff --git a/core/datastore-test/src/main/java/com/crisiscleanup/core/datastore/test/TestDataStoreModule.kt b/core/datastore-test/src/main/java/com/crisiscleanup/core/datastore/test/TestDataStoreModule.kt index fc9422126..22af93931 100644 --- a/core/datastore-test/src/main/java/com/crisiscleanup/core/datastore/test/TestDataStoreModule.kt +++ b/core/datastore-test/src/main/java/com/crisiscleanup/core/datastore/test/TestDataStoreModule.kt @@ -4,6 +4,8 @@ import androidx.datastore.core.DataStore import androidx.datastore.core.DataStoreFactory import com.crisiscleanup.core.datastore.AccountInfo import com.crisiscleanup.core.datastore.AccountInfoProtoSerializer +import com.crisiscleanup.core.datastore.AppMetrics +import com.crisiscleanup.core.datastore.AppMetricsSerializer import com.crisiscleanup.core.datastore.UserPreferences import com.crisiscleanup.core.datastore.UserPreferencesSerializer import com.crisiscleanup.core.datastore.di.DataStoreModule @@ -23,10 +25,10 @@ object TestDataStoreModule { @Provides @Singleton fun providesUserPreferencesDataStore( - userPreferencesSerializer: UserPreferencesSerializer, + serializer: UserPreferencesSerializer, tmpFolder: TemporaryFolder, ): DataStore = - tmpFolder.testUserPreferencesDataStore(userPreferencesSerializer) + tmpFolder.testUserPreferencesDataStore(serializer) @Provides @Singleton @@ -34,12 +36,19 @@ object TestDataStoreModule { serializer: AccountInfoProtoSerializer, tmpFolder: TemporaryFolder, ): DataStore = tmpFolder.testAccountInfoDataStore(serializer) + + @Provides + @Singleton + fun providesAppMetricsDataStore( + serializer: AppMetricsSerializer, + tmpFolder: TemporaryFolder, + ): DataStore = tmpFolder.testAppMetricsDataStore(serializer) } fun TemporaryFolder.testUserPreferencesDataStore( - userPreferencesSerializer: UserPreferencesSerializer = UserPreferencesSerializer(), + serializer: UserPreferencesSerializer = UserPreferencesSerializer(), ) = DataStoreFactory.create( - serializer = userPreferencesSerializer, + serializer = serializer, ) { newFile("user_preferences_test.pb") } @@ -51,3 +60,11 @@ fun TemporaryFolder.testAccountInfoDataStore( ) { newFile("account_info_test.pb") } + +fun TemporaryFolder.testAppMetricsDataStore( + serializer: AppMetricsSerializer = AppMetricsSerializer(), +) = DataStoreFactory.create( + serializer = serializer, +) { + newFile("app_metrics_test.pb") +} diff --git a/core/datastore/build.gradle.kts b/core/datastore/build.gradle.kts index 9a67f1016..8ce84ffc5 100644 --- a/core/datastore/build.gradle.kts +++ b/core/datastore/build.gradle.kts @@ -30,4 +30,6 @@ dependencies { testImplementation(projects.core.testing) testImplementation(projects.core.datastoreTest) + testImplementation(libs.mockk.android) + testImplementation(libs.turbine) } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSource.kt index 895297c55..b0eea38e8 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSource.kt @@ -41,6 +41,8 @@ class LocalAppMetricsDataSource @Inject constructor( link = buildSupport.appLink, isUnsupported = buildSupport.minVersion > appVersionProvider.versionCode, ), + + appInstallVersion = it.appInstallVersion, ) } @@ -58,13 +60,15 @@ class LocalAppMetricsDataSource @Inject constructor( } suspend fun setAppOpen( - appVersion: Long, timestamp: Instant = Clock.System.now(), ) { + val appVersion = appVersionProvider.versionCode appMetrics.updateData { + val installedVersion = it.appInstallVersion it.copy { appOpenVersion = appVersion appOpenSeconds = timestamp.epochSeconds + appInstallVersion = if (installedVersion <= 0) appVersion else installedVersion } } } 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 f3eed3c9b..efa073c2f 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 @@ -58,6 +58,8 @@ class LocalAppPreferencesDataSource @Inject constructor( casesMapBounds = it.casesMapBounds.asExternalModel(), teamMapBounds = it.teamMapBounds.asExternalModel(), + + isWorkScreenTableView = it.isWorkScreenTableView, ) } @@ -73,6 +75,9 @@ class LocalAppPreferencesDataSource @Inject constructor( allowAllAnalytics = false hideGettingStartedVideo = false isMenuTutorialDone = false + shareLocationWithOrg = false + // TODO Bounds + isWorkScreenTableView = false } } } @@ -181,6 +186,12 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { teamMapBounds = bounds.asProto() } } } + + suspend fun saveWorkScreenView(isTableView: Boolean) { + userPreferences.updateData { + it.copy { isWorkScreenTableView = isTableView } + } + } } private fun IncidentMapBoundsProto.asExternalModel() = IncidentCoordinateBounds( diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AccountInfoDataSourceTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AccountInfoDataSourceTest.kt index 991c6b0e0..21417969b 100644 --- a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AccountInfoDataSourceTest.kt +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AccountInfoDataSourceTest.kt @@ -1,7 +1,7 @@ package com.crisiscleanup.core.datastore class AccountInfoDataSourceTest { - // TODO Rewrite tests + // TODO Rewrite tests with testable dependencies // private lateinit var subject: AccountInfoDataSource // // @get:Rule diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSourceTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSourceTest.kt new file mode 100644 index 000000000..51d00a243 --- /dev/null +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/LocalAppMetricsDataSourceTest.kt @@ -0,0 +1,90 @@ +package com.crisiscleanup.core.datastore + +import app.cash.turbine.test +import com.crisiscleanup.core.common.AppVersionProvider +import com.crisiscleanup.core.datastore.test.testAppMetricsDataStore +import com.crisiscleanup.core.model.data.AppOpenInstant +import com.crisiscleanup.core.testing.util.nowTruncateMillis +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Instant +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.time.Duration.Companion.seconds + +class LocalAppMetricsDataSourceTest { + @MockK + lateinit var appVersionProvider: AppVersionProvider + + private lateinit var subject: LocalAppMetricsDataSource + + @get:Rule + val tmpFolder: TemporaryFolder = TemporaryFolder.builder().assureDeletion().build() + + @Before + fun setup() { + MockKAnnotations.init(this) + + subject = LocalAppMetricsDataSource( + tmpFolder.testAppMetricsDataStore(), + appVersionProvider, + ) + } + + private fun mockVersionCodes() { + coEvery { + appVersionProvider.versionCode + } returnsMany listOf(23, 34, 45, 56, 67, 78) + } + + @Test + fun appOpen() = runTest { + mockVersionCodes() + + subject.metrics.test { + val initial = awaitItem() + + assertEquals( + AppOpenInstant( + 0, + Instant.fromEpochSeconds(0), + ), + initial.appOpen, + ) + assertEquals(0, initial.appInstallVersion) + + val firstOpenTimestamp = nowTruncateMillis.minus(10.seconds) + subject.setAppOpen(firstOpenTimestamp) + + val firstOpen = awaitItem() + // Skip first version provided due to metrics sequence accessing version + assertEquals( + AppOpenInstant( + 34, + firstOpenTimestamp, + ), + firstOpen.appOpen, + ) + assertEquals(34, firstOpen.appInstallVersion) + + val secondOpenTimestamp = nowTruncateMillis.minus(3.seconds) + subject.setAppOpen(secondOpenTimestamp) + + val secondOpen = awaitItem() + // Skip another version provided due to metrics sequence accessing version + assertEquals( + AppOpenInstant( + 56, + secondOpenTimestamp, + ), + secondOpen.appOpen, + ) + assertEquals(34, secondOpen.appInstallVersion) + } + } +} 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 7a32e6d9d..80c71e517 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 @@ -8,7 +8,9 @@ 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 @@ -58,6 +60,10 @@ class LocalAppPreferencesDataSourceTest { try { testBody() + + advanceUntilIdle() + yield() + val attempts = values.map(UserData::syncAttempt) onAttempts(attempts) } finally { @@ -102,7 +108,10 @@ class LocalAppPreferencesDataSourceTest { SyncAttempt(20158, 20158, 0), ) for (i in expecteds.indices) { - assertEquals(expecteds[i], attempts[i]) + // Without print statements this will fail at times due to no attempts... + val attempt = attempts[i] + val expected = expecteds[i] + assertEquals(expected, attempt) } } } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMetricsData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMetricsData.kt index 75cdaf352..0750bbb11 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMetricsData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMetricsData.kt @@ -12,6 +12,8 @@ data class AppMetricsData( val switchToProductionApiVersion: Long, val minSupportedAppVersion: MinSupportedAppVersion, + + val appInstallVersion: Long, ) data class MinSupportedAppVersion( 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 fb375e6fc..405179faa 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 @@ -25,4 +25,6 @@ data class UserData( val casesMapBounds: IncidentCoordinateBounds, val teamMapBounds: IncidentCoordinateBounds, + + val isWorkScreenTableView: Boolean, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksite.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksite.kt index 019f408f8..9f61ae957 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksite.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkWorksite.kt @@ -24,7 +24,7 @@ data class NetworkWorksiteFull( @SerialName("case_number") val caseNumber: String, val city: String, - val county: String, + val county: String?, val email: String? = null, val events: List, val favorite: NetworkType?, @@ -174,7 +174,7 @@ data class NetworkWorksiteShort( @SerialName("case_number") val caseNumber: String, val city: String, - val county: String, + val county: String?, // Full does not have this field. Updates should not overwrite @Serializable(InstantSerializer::class) @SerialName("created_at") @@ -282,7 +282,7 @@ data class NetworkWorksitePage( @SerialName("case_number") val caseNumber: String, val city: String, - val county: String, + val county: String?, // Full does not have this field. Updates should not overwrite @Serializable(InstantSerializer::class) @SerialName("created_at") @@ -347,7 +347,7 @@ data class NetworkWorksiteCoreData( @SerialName("case_number") val caseNumber: String, val city: String, - val county: String, + val county: String?, val email: String? = null, val favorite: NetworkType?, val flags: List, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/worksitechange/WorksiteChangeSetOperator.kt b/core/network/src/main/java/com/crisiscleanup/core/network/worksitechange/WorksiteChangeSetOperator.kt index 97dc46235..5ef2d5ac4 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/worksitechange/WorksiteChangeSetOperator.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/worksitechange/WorksiteChangeSetOperator.kt @@ -159,7 +159,7 @@ internal fun NetworkWorksiteFull.getCoreChange( ), caseNumber = caseNumber, city = city.change(coreA.city, coreB.city), - county = county.change(coreA.county, coreB.county), + county = baseChange(county, coreA.county, coreB.county) ?: "", email = baseChange(email, coreA.email, coreB.email) ?: "", // Member of my org/favorite change is performed in a followup call favorite = favorite, diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/worksitechange/WorksiteCoreChangeTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/worksitechange/WorksiteCoreChangeTest.kt index 561834fa9..ed7e820cf 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/worksitechange/WorksiteCoreChangeTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/worksitechange/WorksiteCoreChangeTest.kt @@ -180,7 +180,7 @@ class WorksiteCoreChangeTest { autoContactFrequencyT = fullyDefinedWorksite.autoContactFrequencyT, caseNumber = fullyDefinedWorksite.caseNumber, city = fullyDefinedWorksite.city, - county = fullyDefinedWorksite.county, + county = fullyDefinedWorksite.county ?: "", email = fullyDefinedWorksite.email ?: "", favorite = fullyDefinedWorksite.favorite, formData = emptyList(), @@ -366,7 +366,7 @@ class WorksiteCoreChangeTest { autoContactFrequencyT = fullyDefinedWorksite.autoContactFrequencyT, caseNumber = fullyDefinedWorksite.caseNumber, city = fullyDefinedWorksite.city, - county = fullyDefinedWorksite.county, + county = fullyDefinedWorksite.county ?: "", email = "", favorite = fullyDefinedWorksite.favorite, formData = fullyDefinedWorksite.formData, 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 ff1798375..96c14b15c 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 @@ -1,6 +1,7 @@ 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 @@ -10,7 +11,7 @@ val UserDataNone = UserData( darkThemeConfig = DarkThemeConfig.FOLLOW_SYSTEM, shouldHideOnboarding = false, syncAttempt = SyncAttempt(0, 0, 0), - selectedIncidentId = 0, + selectedIncidentId = EmptyIncident.id, languageKey = "", tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, @@ -19,4 +20,5 @@ val UserDataNone = UserData( shareLocationWithOrg = false, casesMapBounds = IncidentCoordinateBoundsNone, teamMapBounds = IncidentCoordinateBoundsNone, + isWorkScreenTableView = false, ) diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/util/TestDateUtil.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/util/TestDateUtil.kt new file mode 100644 index 000000000..26c4b7a81 --- /dev/null +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/util/TestDateUtil.kt @@ -0,0 +1,9 @@ +package com.crisiscleanup.core.testing.util + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +val nowTruncateMillis: Instant + get() { + return Instant.fromEpochSeconds(Clock.System.now().epochSeconds) + } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt index 722349897..b7e74f1d2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt @@ -440,12 +440,12 @@ private fun ConfirmOldIncidentDialog( ) { val t = LocalAppTranslator.current val confirmContinueKey = if (isCreateWorksite) { - "~~{incident_name} was created {relative_time}. Continue creating a Case for {incident_name}?" + "caseForm.old_incident_case_create_confirm" } else { - "~~{incident_name} was created {relative_time}. Continue editing this Case for {incident_name}?" + "caseForm.old_incident_case_edit_confirm" } CrisisCleanupAlertDialog( - title = t("~~Old Incident"), + title = t("caseForm.old_incident"), confirmButton = { CrisisCleanupTextButton( text = t("actions.yes"), diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesQueryStateManager.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesQueryStateManager.kt index 9149763c5..612ab87f2 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesQueryStateManager.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesQueryStateManager.kt @@ -3,17 +3,21 @@ package com.crisiscleanup.feature.cases import com.crisiscleanup.core.common.throttleLatest import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.repository.CasesFilterRepository +import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.feature.cases.model.CoordinateBoundsDefault import com.crisiscleanup.feature.cases.model.WorksiteQueryStateDefault import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch internal class CasesQueryStateManager( incidentSelector: IncidentSelector, filterRepository: CasesFilterRepository, + preferencesRepository: LocalAppPreferencesRepository, coroutineScope: CoroutineScope, mapChangeDebounceTimeout: Long = 100, ) { @@ -28,14 +32,16 @@ internal class CasesQueryStateManager( var worksiteQueryState = MutableStateFlow(WorksiteQueryStateDefault) init { - incidentSelector.incident.onEach { - worksiteQueryState.value = worksiteQueryState.value.copy(incidentId = it.id) - } + incidentSelector.incident + .onEach { + worksiteQueryState.value = worksiteQueryState.value.copy(incidentId = it.id) + } .launchIn(coroutineScope) - isTableView.onEach { - worksiteQueryState.value = worksiteQueryState.value.copy(isTableView = it) - } + isTableView + .onEach { + worksiteQueryState.value = worksiteQueryState.value.copy(isTableView = it) + } .launchIn(coroutineScope) mapZoom @@ -52,14 +58,24 @@ internal class CasesQueryStateManager( } .launchIn(coroutineScope) - tableViewSort.onEach { - worksiteQueryState.value = worksiteQueryState.value.copy(tableViewSort = it) - } + tableViewSort + .onEach { + worksiteQueryState.value = worksiteQueryState.value.copy(tableViewSort = it) + } .launchIn(coroutineScope) - filterRepository.casesFiltersLocation.onEach { - worksiteQueryState.value = worksiteQueryState.value.copy(filters = it.first) - } + filterRepository.casesFiltersLocation + .onEach { + worksiteQueryState.value = worksiteQueryState.value.copy(filters = it.first) + } .launchIn(coroutineScope) + + coroutineScope.launch { + val isTableViewCached = + preferencesRepository.userPreferences.first().isWorkScreenTableView + if (isTableViewCached != isTableView.value) { + isTableView.value = isTableViewCached + } + } } } diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt index c03ea361e..e1293f79d 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt @@ -157,6 +157,7 @@ class CasesViewModel @Inject constructor( private val qsm = CasesQueryStateManager( incidentSelector, filterRepository, + appPreferencesRepository, viewModelScope, ) @@ -204,6 +205,10 @@ class CasesViewModel @Inject constructor( fun setContentViewType(isTableView: Boolean) { this.isTableView.value = isTableView + viewModelScope.launch { + appPreferencesRepository.setWorkScreenView(isTableView) + } + if (!isTableView) { mapBoundsManager.restoreBounds() } diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/navigation/CasesNavigation.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/navigation/CasesNavigation.kt index f1e294d23..134a37dca 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/navigation/CasesNavigation.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/navigation/CasesNavigation.kt @@ -8,6 +8,8 @@ import androidx.navigation.compose.composable import androidx.navigation.navigation import com.crisiscleanup.core.appnav.RouteConstant.CASES_GRAPH_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASES_ROUTE +import com.crisiscleanup.core.appnav.sharedViewModel +import com.crisiscleanup.feature.cases.CasesViewModel import com.crisiscleanup.feature.cases.ui.CasesAction import com.crisiscleanup.feature.cases.ui.CasesRoute @@ -16,6 +18,7 @@ fun NavController.navigateToCases(navOptions: NavOptions? = null) { } fun NavGraphBuilder.casesGraph( + navController: NavController, nestedGraphs: NavGraphBuilder.() -> Unit, onCasesAction: (CasesAction) -> Unit = { }, filterCases: () -> Unit = {}, @@ -28,7 +31,9 @@ fun NavGraphBuilder.casesGraph( route = CASES_GRAPH_ROUTE, startDestination = CASES_ROUTE, ) { - composable(route = CASES_ROUTE) { + composable(route = CASES_ROUTE) { backStackEntry -> + val viewModel = + backStackEntry.sharedViewModel(navController, CASES_ROUTE) val rememberOnCasesAction = remember(onCasesAction) { { casesAction: CasesAction -> when (casesAction) { @@ -41,6 +46,7 @@ fun NavGraphBuilder.casesGraph( } } CasesRoute( + viewModel, onCasesAction = rememberOnCasesAction, createNewCase = createCase, viewCase = viewCase, diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt index d60737c93..3a238b6bc 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesFilterScreen.kt @@ -166,7 +166,7 @@ internal fun CasesFilterRoute( val closeDialog = { confirmAbandonFilterChange = false } CrisisCleanupAlertDialog( onDismissRequest = closeDialog, - title = t("~~Filter changes"), + title = t("worksiteFilters.filter_changes"), confirmButton = { CrisisCleanupTextButton( text = t("actions.apply_filters"), @@ -178,11 +178,11 @@ internal fun CasesFilterRoute( }, dismissButton = { CrisisCleanupTextButton( - text = t("~~Abandon"), + text = t("actions.abandon"), onClick = onBack, ) }, - text = t("~~Filters have changed. Would you like to apply or abandon the changes?"), + text = t("worksiteFilters.filter_changes_confirmation"), ) } } diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt index 96d8512a1..1c4647853 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt @@ -50,7 +50,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.constraintlayout.compose.ConstraintLayout -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter @@ -113,7 +112,7 @@ import com.crisiscleanup.core.mapmarker.R as mapMarkerR @Composable internal fun CasesRoute( - viewModel: CasesViewModel = hiltViewModel(), + viewModel: CasesViewModel, onCasesAction: (CasesAction) -> Unit = { }, createNewCase: (Long) -> Unit = {}, viewCase: (Long, Long) -> Boolean = { _, _ -> false }, @@ -204,6 +203,7 @@ internal fun CasesRoute( } } CasesScreen( + viewModel, dataProgress = dataProgressMetrics, disasterResId = disasterResId, onSelectIncident = onIncidentSelect, @@ -269,12 +269,12 @@ internal fun CasesRoute( ) } - NonProductionDialog() + NonProductionDialog(viewModel) } @Composable private fun NonProductionDialog( - viewModel: CasesViewModel = hiltViewModel(), + viewModel: CasesViewModel, ) { var showDialog by remember { mutableStateOf(false) } if (viewModel.visualAlertManager.takeNonProductionAppAlert()) { @@ -353,6 +353,7 @@ internal fun NoIncidentsScreen( @Composable internal fun CasesScreen( + viewModel: CasesViewModel, dataProgress: DataProgressMetrics = zeroDataProgress, onSelectIncident: () -> Unit = {}, @DrawableRes disasterResId: Int = commonAssetsR.drawable.ic_disaster_other, @@ -386,6 +387,7 @@ internal fun CasesScreen( Box { if (isTableView) { CasesTableView( + viewModel, isLoadingData = isLoadingData, isTableDataTransient = isTableDataTransient, disasterResId = disasterResId, @@ -824,7 +826,7 @@ private fun MapLayersView( verticalArrangement = listItemSpacedByHalf, ) { Text( - t("~~Map type"), + t("worksiteMap.toggle_map_type"), style = LocalFontStyles.current.header3, ) @@ -843,7 +845,7 @@ private fun MapLayersView( imageVector = CrisisCleanupIcons.NormalMap, onClick = { onToggleSatelliteType(false) }, ) - Text(t("~~Default")) + Text(t("worksiteMap.street_map")) } Column( horizontalAlignment = Alignment.CenterHorizontally, @@ -854,7 +856,7 @@ private fun MapLayersView( imageVector = CrisisCleanupIcons.SatelliteMap, onClick = { onToggleSatelliteType(true) }, ) - Text(t("~~Satellite")) + Text(t("worksiteMap.satellite_map")) } } } diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt index a8f847e95..3860fd424 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt @@ -44,7 +44,6 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.common.ParsedPhoneNumber import com.crisiscleanup.core.commonassets.R @@ -85,7 +84,7 @@ import kotlinx.coroutines.delay @OptIn(ExperimentalFoundationApi::class) @Composable internal fun BoxScope.CasesTableView( - viewModel: CasesViewModel = hiltViewModel(), + viewModel: CasesViewModel, isLoadingData: Boolean = false, isTableDataTransient: Boolean = false, @DrawableRes disasterResId: Int = R.drawable.ic_disaster_other, 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 c13a44877..3a64d580f 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 @@ -162,7 +162,7 @@ private fun IncidentWorksitesCacheScreen( ) { val syncStageMessage = when (syncStage) { IncidentCacheStage.Inactive -> t("appCache.ready_to_sync") - IncidentCacheStage.Start -> t("~~Starting sync...") + IncidentCacheStage.Start -> t("appCache.starting_sync") IncidentCacheStage.Incidents -> t("appCache.syncing_incidents") IncidentCacheStage.WorksitesBounded -> t("appCache.syncing_cases_in_designated_area") IncidentCacheStage.WorksitesPreload -> t("appCache.syncing_nearby_cases") diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt index a41b85324..b4b0edd47 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt @@ -5,6 +5,8 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.crisiscleanup.core.appnav.RouteConstant.MENU_ROUTE +import com.crisiscleanup.core.appnav.sharedViewModel +import com.crisiscleanup.feature.menu.MenuViewModel import com.crisiscleanup.feature.menu.ui.MenuRoute fun NavController.navigateToMenu(navOptions: NavOptions? = null) { @@ -12,6 +14,7 @@ fun NavController.navigateToMenu(navOptions: NavOptions? = null) { } fun NavGraphBuilder.menuScreen( + navController: NavController, openAuthentication: () -> Unit = {}, openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, @@ -20,8 +23,10 @@ fun NavGraphBuilder.menuScreen( openIncidentCache: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { - composable(route = MENU_ROUTE) { + composable(route = MENU_ROUTE) { backStackEntry -> + val viewModel = backStackEntry.sharedViewModel(navController, MENU_ROUTE) MenuRoute( + viewModel, openAuthentication = openAuthentication, openInviteTeammate = openInviteTeammate, openRequestRedeploy = openRequestRedeploy, 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 eecbd543f..0878f8fa0 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 @@ -44,7 +44,6 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.appcomponent.ui.AppTopBar import com.crisiscleanup.core.common.TutorialStep @@ -80,6 +79,7 @@ import com.google.accompanist.permissions.shouldShowRationale @Composable internal fun MenuRoute( + viewModel: MenuViewModel, openAuthentication: () -> Unit = {}, openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, @@ -96,6 +96,7 @@ internal fun MenuRoute( openLists = openLists, openIncidentCache = openIncidentCache, openSyncLogs = openSyncLogs, + viewModel = viewModel, ) } @@ -109,7 +110,7 @@ private fun MenuScreen( openLists: () -> Unit = {}, openIncidentCache: () -> Unit = {}, openSyncLogs: () -> Unit = {}, - viewModel: MenuViewModel = hiltViewModel(), + viewModel: MenuViewModel, ) { val t = LocalAppTranslator.current val translationCount by t.translationCount.collectAsStateWithLifecycle() @@ -379,7 +380,7 @@ private fun MenuScreen( if (viewModel.isDebuggable) { item { - MenuScreenNonProductionView() + MenuScreenNonProductionView(viewModel) } } @@ -626,7 +627,7 @@ private fun GettingStartedSection( @Composable internal fun MenuScreenNonProductionView( - viewModel: MenuViewModel = hiltViewModel(), + viewModel: MenuViewModel, ) { val databaseText = viewModel.databaseVersionText Text( diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt index b017cda10..3c201581f 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -1,6 +1,7 @@ package com.crisiscleanup.sync import android.content.Context +import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.common.di.ApplicationScope import com.crisiscleanup.core.common.log.AppLogger @@ -42,6 +43,7 @@ class AppSyncer @Inject constructor( private val statusRepository: WorkTypeStatusRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val networkMonitor: NetworkMonitor, + translator: KeyResourceTranslator, @ApplicationContext private val context: Context, @Logger(CrisisCleanupLoggers.Sync) private val logger: AppLogger, @ApplicationScope private val applicationScope: CoroutineScope, @@ -56,6 +58,7 @@ class AppSyncer @Inject constructor( context, incidentDataPullReporter, this, + translator, logger, applicationScope, ) diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt b/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt index 73433a745..1fcd0e75f 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/notification/IncidentDataSyncNotifier.kt @@ -8,6 +8,7 @@ import android.content.Context.RECEIVER_NOT_EXPORTED import android.content.Intent import android.content.IntentFilter import android.os.Build +import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.sync.SyncPuller import com.crisiscleanup.core.data.incidentcache.IncidentDataPullReporter @@ -29,6 +30,7 @@ internal class IncidentDataSyncNotifier @Inject constructor( private val appContext: Context, incidentDataPullReporter: IncidentDataPullReporter, private val syncPuller: SyncPuller, + private val translator: KeyResourceTranslator, private val logger: AppLogger, coroutineScope: CoroutineScope, ) { @@ -56,27 +58,36 @@ internal class IncidentDataSyncNotifier @Inject constructor( if (isOngoing && isSyncing ) { - val title = appContext.getString(R.string.syncing_text, incidentName) + val title = translator.translate("~~Syncing {incident_name}", 0) + .replace("{incident_name}", incidentName) val text = notificationMessage.ifBlank { var message = if (isIndeterminate) { - appContext.getString( - R.string.saving_indeterminate_data, - ) + translator.translate("~~Saving data...", 0) } else if (pullType == IncidentPullDataType.WorksitesCore) { - appContext.getString( - R.string.saved_cases_out_of, - savedCount, - dataCount, + translator.translate( + "~~Saved {case_count}/{total_case_count} Cases.", + 0, ) - } else { - appContext.getString( - R.string.saved_full_cases_out_of, - savedCount, - dataCount, + .replace("{case_count}", "$savedCount") + .replace("{total_case_count}", "$dataCount") + } else if (pullType == IncidentPullDataType.WorksitesAdditional) { + translator.translate( + "~~Saved {case_count}/{total_case_count} offline Cases.", + 0, ) + .replace("{case_count}", "$savedCount") + .replace("{total_case_count}", "$dataCount") + } else { + translator.translate("~~Saving more data...", 0) } if (currentStep in 1..stepTotal) { - message = "($currentStep/$stepTotal) $message" + message = translator.translate( + "~~({current_step}/{total_step_count}) {message}", + 0, + ) + .replace("{current_step}", "$currentStep") + .replace("{total_step_count}", "$stepTotal") + .replace("{message}", message) } message } @@ -92,7 +103,7 @@ internal class IncidentDataSyncNotifier @Inject constructor( .progress(progress) .addAction( R.drawable.close, - appContext.getString(R.string.stop_syncing), + translator.translate("~~Stop syncing", 0), stopSyncIntent, ) .setOnlyAlertOnce(true) diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt index 4e403b53b..cd772246a 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncMediaWorker.kt @@ -6,12 +6,12 @@ import androidx.tracing.traceAsync import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters +import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult -import com.crisiscleanup.sync.R import com.crisiscleanup.sync.initializers.SYNC_MEDIA_NOTIFICATION_ID import com.crisiscleanup.sync.initializers.SyncMediaConstraints import com.crisiscleanup.sync.initializers.channelNotificationManager @@ -28,12 +28,13 @@ internal class SyncMediaWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, private val syncPusher: SyncPusher, + private val translator: KeyResourceTranslator, private val syncLogger: SyncLogger, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(appContext, workerParams) { override suspend fun getForegroundInfo() = appContext.syncForegroundInfo( SYNC_MEDIA_NOTIFICATION_ID, - text = appContext.getString(R.string.sync_media_notification_text), + text = translator.translate("~~Syncing photos and images", 0), ) override suspend fun doWork() = withContext(ioDispatcher) { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt index 773834a18..e20ef2fce 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt @@ -6,12 +6,12 @@ import androidx.tracing.traceAsync import androidx.work.CoroutineWorker import androidx.work.OneTimeWorkRequestBuilder import androidx.work.WorkerParameters +import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult -import com.crisiscleanup.sync.R import com.crisiscleanup.sync.initializers.SYNC_WORKSITES_NOTIFICATION_ID import com.crisiscleanup.sync.initializers.SyncConstraints import com.crisiscleanup.sync.initializers.channelNotificationManager @@ -28,12 +28,13 @@ internal class SyncWorksitesWorker @AssistedInject constructor( @Assisted private val appContext: Context, @Assisted workerParams: WorkerParameters, private val syncPusher: SyncPusher, + private val translator: KeyResourceTranslator, private val syncLogger: SyncLogger, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(appContext, workerParams) { override suspend fun getForegroundInfo() = appContext.syncForegroundInfo( SYNC_WORKSITES_NOTIFICATION_ID, - text = appContext.getString(R.string.sync_cases_notification_text), + text = translator.translate("~~Syncing Cases", 0), ) override suspend fun doWork() = withContext(ioDispatcher) { diff --git a/sync/work/src/main/res/values/strings.xml b/sync/work/src/main/res/values/strings.xml index 645be119c..4bd8c56e9 100644 --- a/sync/work/src/main/res/values/strings.xml +++ b/sync/work/src/main/res/values/strings.xml @@ -3,12 +3,4 @@ Sync Background tasks for Crisis Cleanup Syncing data - Syncing photos and images - Syncing Cases - Syncing incident Cases - Syncing %s - Saved %d/%d Cases. - Saved %d/%d offline Cases. - Saving data… - Stop syncing \ No newline at end of file