diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt index 1b684171c..a331892c1 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt @@ -130,6 +130,10 @@ class AppSyncer @Inject constructor() : SyncPuller, SyncPusher { override suspend fun syncPullStatuses() = SyncResult.NotAttempted("") + override fun appPullAppConfig() {} + + override suspend fun syncPullAppConfig() = SyncResult.NotAttempted("") + override fun appPushWorksite(worksiteId: Long, scheduleMediaSync: Boolean) {} override suspend fun syncPushWorksitesAsync() = CompletableDeferred(SyncResult.NotAttempted("")) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0792389e3..53714dfc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 277 + val buildVersion = 281 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 6897f1077..c19fb31f2 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -172,7 +172,7 @@ class MainActivityViewModel @Inject constructor( syncFullWorksites = false, ) accountDataRefresher.updateMyOrganization(true) - accountDataRefresher.updateApprovedIncidents() + accountDataRefresher.updateProfileIncidentsData() logger.setAccountId(it.id.toString()) } else { @@ -206,6 +206,7 @@ class MainActivityViewModel @Inject constructor( syncPuller.appPullLanguage() syncPuller.appPullStatuses() + syncPuller.appPullAppConfig() syncPusher.scheduleSyncMedia() diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 837438f21..e6e7d7a14 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -7,6 +7,9 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import com.crisiscleanup.core.appnav.RouteConstant.ACCOUNT_RESET_PASSWORD_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.ACCOUNT_TRANSFER_ORG_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.CASES_FILTER_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASES_GRAPH_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASES_SEARCH_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASE_ADD_FLAG_ROUTE @@ -16,7 +19,15 @@ import com.crisiscleanup.core.appnav.RouteConstant.CASE_EDITOR_SEARCH_ADDRESS_RO import com.crisiscleanup.core.appnav.RouteConstant.CASE_HISTORY_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASE_SHARE_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.INCIDENT_WORKSITES_CACHE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.INVITE_TEAMMATE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.LISTS_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.REQUEST_REDEPLOY_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.USER_FEEDBACK_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_CASE_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.VIEW_CASE_TRANSFER_WORK_TYPES_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_IMAGE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_LIST_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_TEAM_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.WORKSITE_IMAGES_ROUTE import com.crisiscleanup.core.appnav.navigateToExistingCase import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier @@ -161,6 +172,8 @@ fun CrisisCleanupNavHost( val searchCasesOnBack = rememberBackOnRoute(navController, onBack, CASES_SEARCH_ROUTE) + val filterCasesOnBack = rememberBackOnRoute(navController, onBack, CASES_FILTER_ROUTE) + val caseEditorOnBack = rememberBackStartingRoute(navController, onBack, CASE_EDITOR_ROUTE) val searchAddressOnBack = @@ -168,6 +181,8 @@ fun CrisisCleanupNavHost( val moveLocationOnBack = rememberBackOnRoute(navController, onBack, CASE_EDITOR_MAP_MOVE_LOCATION_ROUTE) + val viewCaseOnBack = rememberBackStartingRoute(navController, onBack, VIEW_CASE_ROUTE) + val transferOnBack = rememberBackStartingRoute(navController, onBack, VIEW_CASE_TRANSFER_WORK_TYPES_ROUTE) @@ -177,6 +192,9 @@ fun CrisisCleanupNavHost( val historyOnBack = rememberBackOnRoute(navController, onBack, CASE_HISTORY_ROUTE) + val viewTeamOnBack = rememberBackStartingRoute(navController, onBack, VIEW_TEAM_ROUTE) + + val viewSingleImageOnBack = rememberBackStartingRoute(navController, onBack, VIEW_IMAGE_ROUTE) val worksiteImagesOnBack = rememberBackStartingRoute(navController, onBack, WORKSITE_IMAGES_ROUTE) @@ -184,6 +202,15 @@ fun CrisisCleanupNavHost( rememberBackOnRoute(navController, onBack, INCIDENT_WORKSITES_CACHE_ROUTE) val openIncidentCache = navController::navigateToIncidentWorksitesCache + val viewListsOnBack = rememberBackOnRoute(navController, onBack, LISTS_ROUTE) + val viewListOnBack = rememberBackStartingRoute(navController, onBack, VIEW_LIST_ROUTE) + val inviteTeammatesOnBack = rememberBackOnRoute(navController, onBack, INVITE_TEAMMATE_ROUTE) + val requestRedeployOnBack = rememberBackOnRoute(navController, onBack, REQUEST_REDEPLOY_ROUTE) + val userFeedbackOnBack = rememberBackOnRoute(navController, onBack, USER_FEEDBACK_ROUTE) + + val resetPasswordOnBack = rememberBackOnRoute(navController, onBack, ACCOUNT_RESET_PASSWORD_ROUTE) + val requestAccessOnBack = rememberBackStartingRoute(navController, onBack, ACCOUNT_TRANSFER_ORG_ROUTE) + NavHost( navController = navController, startDestination = startDestination, @@ -193,11 +220,11 @@ fun CrisisCleanupNavHost( navController, nestedGraphs = { casesSearchScreen(searchCasesOnBack, viewCase) - casesFilterScreen(onBack) + casesFilterScreen(filterCasesOnBack) caseEditorScreen(navController, caseEditorOnBack) caseEditSearchAddressScreen(navController, searchAddressOnBack) caseEditMoveLocationOnMapScreen(moveLocationOnBack) - existingCaseScreen(navController, onBack) + existingCaseScreen(navController, viewCaseOnBack) existingCaseTransferWorkTypesScreen(transferOnBack) caseAddFlagScreen(addFlagOnBack, replaceRouteViewCase) caseShareScreen(shareOnBack) @@ -213,7 +240,7 @@ fun CrisisCleanupNavHost( dashboardScreen() teamsScreen( nestedGraphs = { - viewTeamScreen(onBack) + viewTeamScreen(viewTeamOnBack) }, openAuthentication = openAuthentication, openViewTeam = navController::navigateToViewTeam, @@ -229,14 +256,14 @@ fun CrisisCleanupNavHost( openSyncLogs = navController::navigateToSyncInsights, ) incidentWorksitesCache(incidentWorksitesCacheOnBack) - viewSingleImageScreen(onBack) + viewSingleImageScreen(viewSingleImageOnBack) viewWorksiteImagesScreen(worksiteImagesOnBack) - inviteTeammateScreen(onBack) - requestRedeployScreen(onBack) - userFeedbackScreen(onBack) - listsScreen(navController, onBack) + inviteTeammateScreen(inviteTeammatesOnBack) + requestRedeployScreen(requestRedeployOnBack) + userFeedbackScreen(userFeedbackOnBack) + listsScreen(navController, viewListsOnBack) viewListScreen( - onBack, + viewListOnBack, openList = openList, openWorksite = openViewCase, ) @@ -244,14 +271,14 @@ fun CrisisCleanupNavHost( resetPasswordScreen( isAuthenticated = true, - onBack = onBack, - closeResetPassword = onBack, + onBack = resetPasswordOnBack, + closeResetPassword = resetPasswordOnBack, ) requestAccessScreen( true, - onBack = onBack, - closeRequestAccess = onBack, + onBack = requestAccessOnBack, + closeRequestAccess = requestAccessOnBack, openAuth = {}, openForgotPassword = {}, ) diff --git a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt index 79325b3c4..3539d8eca 100644 --- a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt +++ b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt @@ -18,8 +18,8 @@ import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.model.AutocompletePrediction import com.google.android.libraries.places.api.model.AutocompleteSessionToken import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.model.PlaceTypes import com.google.android.libraries.places.api.model.RectangularBounds -import com.google.android.libraries.places.api.model.TypeFilter import com.google.android.libraries.places.api.net.FetchPlaceRequest import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest import com.google.android.libraries.places.api.net.PlacesClient @@ -143,15 +143,14 @@ class GooglePlaceAddressSearchRepository @Inject constructor( } val hasBounds = southwest != null && northeast != null - val bounds = - if (hasBounds) RectangularBounds.newInstance(southwest!!, northeast!!) else null + val bounds = if (hasBounds) RectangularBounds.newInstance(southwest, northeast) else null val request = FindAutocompletePredictionsRequest.builder() // Call either setLocationBias() OR setLocationRestriction(). .setLocationBias(bounds) // .setLocationRestriction(bounds) .setOrigin(center) .setCountries(countryCodes) - .setTypesFilter(listOf(TypeFilter.ADDRESS.toString().lowercase())) + .setTypesFilter(listOf(PlaceTypes.ADDRESS)) .setQuery(query) .setSessionToken(getSessionToken()) .build() diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt index 70d9875a5..fa7a67905 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt @@ -36,6 +36,9 @@ interface SyncPuller { fun appPullStatuses() suspend fun syncPullStatuses(): SyncResult + fun appPullAppConfig() + suspend fun syncPullAppConfig(): SyncResult + fun appPullIncidents() = appPullIncidentData( cancelOngoing = true, forcePullIncidents = true, diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 37cbbe320..747888ff2 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -34,4 +34,5 @@ dependencies { testImplementation(projects.core.testing) testImplementation(projects.core.datastoreTest) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) } \ No newline at end of file diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt new file mode 100644 index 000000000..fc8d79023 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt @@ -0,0 +1,105 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.database.dao.WorksiteChangeDao +import com.crisiscleanup.core.model.data.closedWorkTypeStatuses +import com.crisiscleanup.core.model.data.statusFromLiteral +import com.crisiscleanup.core.network.worksitechange.WorkTypeSnapshot +import com.crisiscleanup.core.network.worksitechange.WorksiteChange +import kotlinx.serialization.json.Json +import javax.inject.Inject + +interface WorkTypeAnalyzer { + fun countUnsyncedClaimCloseWork( + orgId: Long, + incidentId: Long, + ignoreWorksiteIds: Set, + ): ClaimCloseCounts +} + +private val WorkTypeSnapshot.WorkType.isClosed: Boolean + get() { + val workTypeStatus = statusFromLiteral(status) + return closedWorkTypeStatuses.contains(workTypeStatus) + } + +class WorksiteChangeWorkTypeAnalyzer @Inject constructor( + private val worksiteChangeDao: WorksiteChangeDao, +) : WorkTypeAnalyzer { + override fun countUnsyncedClaimCloseWork( + orgId: Long, + incidentId: Long, + ignoreWorksiteIds: Set, + ): ClaimCloseCounts { + val worksiteChangesLookup = mutableMapOf>() + worksiteChangeDao.getOrgChanges(orgId) + .filter { + !ignoreWorksiteIds.contains(it.entity.worksiteId) + } + .onEach { + with(it.entity) { + val entry = worksiteChangesLookup[worksiteId] + worksiteChangesLookup[worksiteId] = if (entry == null) { + Pair(changeData, null) + } else { + Pair(entry.first, changeData) + } + } + } + + val workTypeChanges = + mutableListOf>() + + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + worksiteChangesLookup.forEach { entry -> + val (firstSerializedChange, lastSerializedChange) = entry.value + val firstChange = json.decodeFromString(firstSerializedChange) + firstChange.start?.let { firstSnapshot -> + if (firstSnapshot.core.networkId > 0) { + val lastSnapshot = lastSerializedChange?.let { + json.decodeFromString(lastSerializedChange).change + } ?: firstChange.change + if (lastSnapshot.core.incidentId == incidentId) { + val startWorkLookup = firstSnapshot.workTypes.associateBy { it.localId } + val lastWorkLookup = lastSnapshot.workTypes.associateBy { it.localId } + for ((id, startWorkType) in startWorkLookup) { + // TODO Test coverage on last work type is null + val lastWorkType = lastWorkLookup[id]?.workType + val change = Pair(startWorkType.workType, lastWorkType) + workTypeChanges.add(change) + } + } + } + } + } + + var claimCount = 0 + var closeCount = 0 + for ((startWorkType, lastWorkType) in workTypeChanges) { + val wasClaimed = startWorkType.orgClaim == orgId + val isClaimed = lastWorkType?.orgClaim == orgId + if (wasClaimed != isClaimed) { + claimCount += if (isClaimed) 1 else -1 + + if (isClaimed && lastWorkType.isClosed) { + closeCount++ + } + } else if (isClaimed) { + val wasClosed = startWorkType.isClosed + val isClosed = lastWorkType.isClosed + if (wasClosed != isClosed) { + closeCount += if (isClosed) 1 else -1 + } + } + } + + return ClaimCloseCounts(claimCount = claimCount, closeCount = closeCount) + } +} + +data class ClaimCloseCounts( + val claimCount: Int, + val closeCount: Int, +) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt index 001c22584..8d58eacba 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt @@ -6,8 +6,11 @@ import com.crisiscleanup.core.data.AppIncidentMapTracker import com.crisiscleanup.core.data.IncidentMapTracker import com.crisiscleanup.core.data.IncidentSelectManager import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorkTypeAnalyzer +import com.crisiscleanup.core.data.WorksiteChangeWorkTypeAnalyzer import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository +import com.crisiscleanup.core.data.repository.AppConfigRepository import com.crisiscleanup.core.data.repository.AppDataManagementRepository import com.crisiscleanup.core.data.repository.AppEndOfLifeRepository import com.crisiscleanup.core.data.repository.AppMetricsRepository @@ -16,8 +19,10 @@ import com.crisiscleanup.core.data.repository.CaseHistoryRepository import com.crisiscleanup.core.data.repository.CasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountDataRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountUpdateRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupAppConfigRepository import com.crisiscleanup.core.data.repository.CrisisCleanupCasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupDataManagementRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupIncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.CrisisCleanupListsRepository import com.crisiscleanup.core.data.repository.CrisisCleanupLocalImageRepository import com.crisiscleanup.core.data.repository.CrisisCleanupOrgVolunteerRepository @@ -28,6 +33,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupWorkTypeStatusReposit import com.crisiscleanup.core.data.repository.CrisisCleanupWorksiteChangeRepository import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.IncidentCacheRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentWorksitesCacheRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -215,4 +221,15 @@ interface DataModule { fun bindsIncidentCacheRepository( repository: IncidentWorksitesCacheRepository, ): IncidentCacheRepository + + @Binds + fun bindsAppConfigRepository(repository: CrisisCleanupAppConfigRepository): AppConfigRepository + + @Binds + fun bindsIncidentClaimThresholdRepository( + repository: CrisisCleanupIncidentClaimThresholdRepository, + ): IncidentClaimThresholdRepository + + @Binds + fun bindsWorkTypeAnalyzer(analyzer: WorksiteChangeWorkTypeAnalyzer): WorkTypeAnalyzer } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt index b41cfde0a..249a16966 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt @@ -20,6 +20,7 @@ fun NetworkIncident.asEntity() = IncidentEntity( turnOnRelease = turnOnRelease, isArchived = isArchived ?: false, type = type, + ignoreClaimingThresholds = ignoreClaimingThresholds ?: false, ) fun NetworkIncident.locationsAsEntity(): List = diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt index dfdbec8a6..16af9b161 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt @@ -7,6 +7,7 @@ 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.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.profilePictureUrl import kotlinx.coroutines.CoroutineDispatcher @@ -18,6 +19,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes @Singleton class AccountDataRefresher @Inject constructor( @@ -25,11 +27,13 @@ class AccountDataRefresher @Inject constructor( private val networkDataSource: CrisisCleanupNetworkDataSource, private val accountDataRepository: AccountDataRepository, private val organizationsRepository: OrganizationsRepository, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, private val accountEventBus: AccountEventBus, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, ) { private var accountDataUpdateTime = Instant.fromEpochSeconds(0) + private var incidentClaimThresholdTime = Instant.fromEpochSeconds(0) private suspend fun refreshAccountData( syncTag: String, @@ -43,7 +47,7 @@ class AccountDataRefresher @Inject constructor( return } - logger.logCapture("Syncing $syncTag") + logger.logCapture("Refreshing $syncTag") val accountId = accountDataRepository.accountData.first().id try { @@ -58,7 +62,29 @@ class AccountDataRefresher @Inject constructor( profile.activeRoles!!, ) - accountDataUpdateTime = Clock.System.now() + profile.internalState?.incidentThresholdLookup?.let { + val incidentThresholds = it.mapNotNull { entry -> + entry.key.toLongOrNull()?.let { incidentId -> + val thresholds = entry.value + if (thresholds.claimedCount != null && thresholds.closedRatio != null) { + return@mapNotNull IncidentClaimThreshold( + incidentId = incidentId, + claimedCount = thresholds.claimedCount!!, + closedRatio = thresholds.closedRatio!!, + ) + } + } + null + } + incidentClaimThresholdRepository.saveIncidentClaimThresholds( + accountId, + incidentThresholds, + ) + } + + val now = Clock.System.now() + accountDataUpdateTime = now + incidentClaimThresholdTime = now } } catch (e: Exception) { logger.logException(e) @@ -66,7 +92,7 @@ class AccountDataRefresher @Inject constructor( } suspend fun updateProfilePicture() { - refreshAccountData("profile pic", false) + refreshAccountData("profile-pic", false) } suspend fun updateMyOrganization(force: Boolean) = withContext(ioDispatcher) { @@ -77,10 +103,18 @@ class AccountDataRefresher @Inject constructor( } suspend fun updateAcceptedTerms() { - refreshAccountData("accept terms", true) + refreshAccountData("accept-terms", true) + } + + // Approved Incidents and Incident thresholds + suspend fun updateProfileIncidentsData(force: Boolean = false) { + refreshAccountData("profile-incidents-data", force) } - suspend fun updateApprovedIncidents(force: Boolean = false) { - refreshAccountData("approved incidents", force) + suspend fun updateIncidentClaimThreshold() { + refreshAccountData( + "incident-claim-threshold", + Clock.System.now().minus(incidentClaimThresholdTime) > 5.minutes, + ) } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt new file mode 100644 index 000000000..338e32576 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt @@ -0,0 +1,28 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.datastore.AppConfigDataSource +import com.crisiscleanup.core.model.data.AppConfigData +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +interface AppConfigRepository { + val appConfig: Flow + + suspend fun pullAppConfig() +} + +class CrisisCleanupAppConfigRepository @Inject constructor( + private val networkDataSource: CrisisCleanupNetworkDataSource, + private val appConfigDataSource: AppConfigDataSource, +) : AppConfigRepository { + override val appConfig = appConfigDataSource.appConfigData + + override suspend fun pullAppConfig() { + val thresholds = networkDataSource.getClaimThresholds() + appConfigDataSource.setClaimThresholds( + thresholds.workTypeCount, + thresholds.workTypeClosedRatio, + ) + } +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt index 751fd8878..b19e051c1 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.database.dao.WorksiteDaoPlus import com.crisiscleanup.core.database.dao.fts.rebuildIncidentFts import com.crisiscleanup.core.database.dao.fts.rebuildOrganizationFts import com.crisiscleanup.core.database.dao.fts.rebuildWorksiteTextFts +import com.crisiscleanup.core.datastore.AppMaintenanceDataSource import com.crisiscleanup.core.model.data.CasesFilter import com.crisiscleanup.core.model.data.InitialIncidentWorksitesCachePreferences import kotlinx.coroutines.CoroutineDispatcher @@ -67,11 +68,14 @@ class CrisisCleanupDataManagementRepository @Inject constructor( private val workTypeStatusRepository: WorkTypeStatusRepository, private val casesFilterRepository: CasesFilterRepository, private val appMetricsRepository: LocalAppMetricsRepository, + private val maintenanceDataSource: AppMaintenanceDataSource, private val accountEventBus: AccountEventBus, @ApplicationScope private val externalScope: CoroutineScope, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : AppDataManagementRepository { + private val isRebuildingFts = AtomicBoolean() + override val clearingAppDataStep = MutableStateFlow(ClearAppDataStep.None) override val isAppDataCleared = clearingAppDataStep.map { it == ClearAppDataStep.Cleared } @@ -81,9 +85,21 @@ class CrisisCleanupDataManagementRepository @Inject constructor( private val isClearingAppData = AtomicBoolean() override suspend fun rebuildFts() { - incidentDaoPlus.rebuildIncidentFts() - organizationDaoPlus.rebuildOrganizationFts() - worksiteDaoPlus.rebuildWorksiteTextFts() + if (!isRebuildingFts.compareAndSet(false, true)) { + return + } + + try { + val rebuildVersion = maintenanceDataSource.maintenanceData.first().ftsRebuildVersion + if (rebuildVersion < 260) { + incidentDaoPlus.rebuildIncidentFts() + organizationDaoPlus.rebuildOrganizationFts() + worksiteDaoPlus.rebuildWorksiteTextFts() + maintenanceDataSource.setFtsRebuildVersion(260) + } + } finally { + isRebuildingFts.set(false) + } } override fun clearAppData() { 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 d43778856..f3265c1b9 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 @@ -85,4 +85,8 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setSyncMediaImmediate(syncImmediate: Boolean) { preferencesDataSource.saveSyncMediaImmediate(syncImmediate) } + + override suspend fun setMapSatelliteView(isSatellite: Boolean) { + preferencesDataSource.saveMapSatelliteView(isSatellite) + } } 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 e9102a4cb..c7a46ae43 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 @@ -300,7 +300,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( if (syncPlan.syncIncidents) { logStage(incidentId, IncidentCacheStage.Incidents) - accountDataRefresher.updateApprovedIncidents(true) + accountDataRefresher.updateProfileIncidentsData(true) incidentsRepository.pullIncidents(true) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt new file mode 100644 index 000000000..98f5145a9 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt @@ -0,0 +1,108 @@ +package com.crisiscleanup.core.data.repository + +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.data.ClaimCloseCounts +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorkTypeAnalyzer +import com.crisiscleanup.core.database.dao.IncidentDao +import com.crisiscleanup.core.database.dao.IncidentDaoPlus +import com.crisiscleanup.core.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.IncidentClaimThreshold +import kotlinx.coroutines.flow.first +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.math.ceil + +interface IncidentClaimThresholdRepository { + suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) + + fun onWorksiteCreated(worksiteId: Long) + + suspend fun isWithinClaimCloseThreshold(worksiteId: Long, additionalClaimCount: Int): Boolean +} + +@Singleton +class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( + private val incidentDao: IncidentDao, + private val incidentDaoPlus: IncidentDaoPlus, + private val accountInfoDataSource: AccountInfoDataSource, + private val workTypeAnalyzer: WorkTypeAnalyzer, + private val appConfigRepository: AppConfigRepository, + private val incidentSelector: IncidentSelector, + @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, +) : IncidentClaimThresholdRepository { + private val worksitesCreated = ConcurrentHashMap() + + override fun onWorksiteCreated(worksiteId: Long) { + worksitesCreated.put(worksiteId, true) + } + + override suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) { + try { + incidentDaoPlus.saveIncidentThresholds(accountId, incidentThresholds) + } catch (e: Exception) { + logger.logException(e) + } + } + + override suspend fun isWithinClaimCloseThreshold( + worksiteId: Long, + additionalClaimCount: Int, + ): Boolean { + if (additionalClaimCount <= 0) { + return true + } + + val incidentId = incidentSelector.incidentId.first() + + val accountData = accountInfoDataSource.accountData.first() + val accountId = accountData.id + + val thresholdConfig = appConfigRepository.appConfig.first() + val claimCountThreshold = thresholdConfig.claimCountThreshold + val closeRatioThreshold = thresholdConfig.closedClaimRatioThreshold + + val currentIncidentThreshold = incidentDao.getIncidentClaimThreshold( + accountId = accountId, + incidentId = incidentId, + ) + val userClaimCount = currentIncidentThreshold?.userClaimCount ?: 0 + val userCloseRatio = currentIncidentThreshold?.userCloseRatio ?: 0.0f + + var unsyncedCounts = ClaimCloseCounts(0, 0) + if (!worksitesCreated.containsKey(worksiteId)) { + try { + val orgId = accountData.org.id + unsyncedCounts = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId = orgId, + incidentId = incidentId, + worksitesCreated.keys, + ) + } catch (e: Exception) { + logger.logException(e) + } + } + val unsyncedClaimCount = unsyncedCounts.claimCount + + val claimCount = userClaimCount + unsyncedClaimCount + val closeRatio = if (claimCount > 0) { + val userCloseCount = ceil(userCloseRatio * userClaimCount) + val closeCount = userCloseCount + unsyncedCounts.closeCount + closeCount / claimCount + } else { + userCloseRatio + } + + return claimCount < claimCountThreshold || + closeRatio >= closeRatioThreshold + } +} 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 f7ca42221..7206f71d7 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 @@ -42,4 +42,6 @@ interface LocalAppPreferencesRepository { suspend fun setWorkScreenView(isTableView: Boolean) suspend fun setSyncMediaImmediate(syncImmediate: Boolean) + + suspend fun setMapSatelliteView(isSatellite: Boolean) } 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 1063dbb3e..9154537a4 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 @@ -68,6 +68,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( "turn_on_release", "active_phone_number", "is_archived", + "ignore_claiming_thresholds", ) private val fullIncidentQueryFields: List = incidentsQueryFields.toMutableList().also { it.add("form_fields") } diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt new file mode 100644 index 000000000..189a48277 --- /dev/null +++ b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt @@ -0,0 +1,412 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.database.dao.WorksiteChangeDao +import com.crisiscleanup.core.database.model.PopulatedWorksiteChange +import com.crisiscleanup.core.database.model.WorksiteChangeEntity +import com.crisiscleanup.core.model.data.WorkTypeStatus +import com.crisiscleanup.core.network.worksitechange.CoreSnapshot +import com.crisiscleanup.core.network.worksitechange.WorkTypeSnapshot +import com.crisiscleanup.core.network.worksitechange.WorksiteChange +import com.crisiscleanup.core.network.worksitechange.WorksiteSnapshot +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class WorkTypeAnalyzerTest { + @MockK + private lateinit var worksiteChangeDao: WorksiteChangeDao + + private lateinit var workTypeAnalyzer: WorkTypeAnalyzer + + private val json = Json {} + + @Before + fun setUp() { + MockKAnnotations.init(this) + + workTypeAnalyzer = WorksiteChangeWorkTypeAnalyzer(worksiteChangeDao) + } + + private fun makeWorksiteChange( + snapshotStart: WorksiteSnapshot?, + snapshotChange: WorksiteSnapshot, + worksiteId: Long, + ) = PopulatedWorksiteChange( + WorksiteChangeEntity( + id = 0, + appVersion = 0, + organizationId = 0, + worksiteId = worksiteId, + syncUuid = "", + changeModelVersion = 0, + changeData = json.encodeToString( + WorksiteChange( + start = snapshotStart, + change = snapshotChange, + ), + ), + createdAt = Clock.System.now(), + saveAttempt = 0, + archiveAction = "", + ), + ) + + private fun worksiteChange10( + incidentId: Long = 152, + worksiteId: Long = 342, + orgId: Long = 52, + networkWorksiteId: Long = 40, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1, WorkTypeStatus.ClosedOutOfScope, orgId), + ), + ), + worksiteId, + ) + + private fun worksiteChangeN10( + incidentId: Long = 152, + worksiteId: Long = 343, + orgId: Long = 52, + networkWorksiteId: Long = 41, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1, orgId = orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1), + ), + ), + worksiteId, + ) + + private fun worksiteChange21( + incidentId: Long = 152, + worksiteId: Long = 344, + orgId: Long = 52, + networkWorksiteId: Long = 42, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedOutOfScope), + makeWorkTypeSnapshot(3), + makeWorkTypeSnapshot(4), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedRejected), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedDuplicate, orgId), + makeWorkTypeSnapshot(4, orgId = orgId), + ), + ), + worksiteId, + ) + + private fun worksiteChangeN01( + incidentId: Long = 152, + worksiteId: Long = 345, + orgId: Long = 52, + networkWorksiteId: Long = 43, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1, WorkTypeStatus.ClosedCompleted, orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1, orgId = orgId), + ), + ), + worksiteId, + ) + + @Test + fun separateChanges() = runTest { + var dbChangeCounter = 0 + val dbChanges = listOf( + worksiteChange10(), + worksiteChangeN10(), + worksiteChange21(), + worksiteChangeN01(), + ) + every { worksiteChangeDao.getOrgChanges(52) } answers { + listOf(dbChanges[dbChangeCounter++]) + } + + val expectedCounts = listOf( + ClaimCloseCounts(1, 1), + ClaimCloseCounts(-1, 0), + ClaimCloseCounts(2, 1), + ClaimCloseCounts(0, -1), + ) + for (expected in expectedCounts) { + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + } + + @Test + fun combinedChanges() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(), + worksiteChangeN10(), + worksiteChange21(), + ) + + val expected = ClaimCloseCounts(2, 2) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + + @Test + fun localWorksiteChanges() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(worksiteId = 11), + worksiteChangeN10(worksiteId = 110), + worksiteChange21(worksiteId = 21), + ) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + setOf(11, 21, 110), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun noNetworkWorksiteId() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(networkWorksiteId = 0), + worksiteChangeN10(networkWorksiteId = 0), + worksiteChange21(networkWorksiteId = 0), + ) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun differentIncident() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(incidentId = 52), + worksiteChangeN10(), + worksiteChange21(), + ) + + val expected = ClaimCloseCounts(1, 1) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + + @Test + fun noDistinguishingClaimChanges() = runTest { + val orgId = 42L + val incidentId = 152L + val worksiteChange = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.OpenUnresponsive), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedIncomplete, orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.NeedUnfilled), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedDoneByOthers, orgId), + ), + ), + 152, + ) + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf(worksiteChange) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun noDistinguishingCloseChange() = runTest { + val orgId = 42L + val incidentId = 152L + val worksiteChange = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedOutOfScope), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedIncomplete), + makeWorkTypeSnapshot(4, orgId = orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + // No change in claim + makeWorkTypeSnapshot(2, WorkTypeStatus.OpenUnresponsive), + // Claimed not closed + makeWorkTypeSnapshot(3, WorkTypeStatus.OpenAssigned, orgId), + // Unclaimed closed + makeWorkTypeSnapshot(4, WorkTypeStatus.ClosedDoneByOthers), + ), + ), + 152, + ) + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf(worksiteChange) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun multipleCommits() = runTest { + val orgId = 42L + val worksiteId = 88L + val incidentId = 152L + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf( + worksiteChangeN10(worksiteId = worksiteId), + worksiteChange10(worksiteId = worksiteId), + worksiteChange21(worksiteId = worksiteId), + worksiteChangeN01(worksiteId = worksiteId), + ) + + val expected = ClaimCloseCounts(0, 0) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + assertEquals(expected, actual) + } +} + +private fun makeWorksiteSnapshot( + incidentId: Long = 152, + networkWorksiteId: Long = 6252, + workTypes: List = emptyList(), +) = WorksiteSnapshot( + makeCoreSnapshot( + incidentId = incidentId, + networkId = networkWorksiteId, + ), + emptyList(), + emptyList(), + workTypes, +) + +private fun makeCoreSnapshot( + incidentId: Long = 152, + networkId: Long = 6252, +) = emptyCoreSnapshot.copy( + incidentId = incidentId, + networkId = networkId, +) + +private val emptyCoreSnapshot = CoreSnapshot( + id = 0, + address = "", + autoContactFrequencyT = "", + caseNumber = "", + city = "", + county = "", + createdAt = null, + email = null, + favoriteId = null, + formData = emptyMap(), + incidentId = 0, + keyWorkTypeId = null, + latitude = 0.0, + longitude = 0.0, + name = "", + networkId = 0, + phone1 = "", + phone2 = "", + postalCode = "", + reportedBy = null, + state = "", + svi = null, + updatedAt = null, + isAssignedToOrgMember = false, +) + +private fun makeWorkTypeSnapshot( + localId: Long, + status: WorkTypeStatus = WorkTypeStatus.OpenUnassigned, + orgId: Long? = null, +) = emptyWorkTypeSnapshot.copy( + localId = localId, + workType = emptyWorkTypeSnapshot.workType.copy( + orgClaim = orgId, + status = status.literal, + ), +) + +private val emptyWorkTypeSnapshot = WorkTypeSnapshot( + localId = 0, + workType = WorkTypeSnapshot.WorkType( + id = 0, + orgClaim = null, + status = "", + workType = "", + ), +) diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt new file mode 100644 index 000000000..a2d52ac36 --- /dev/null +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt @@ -0,0 +1,298 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.data.ClaimCloseCounts +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorkTypeAnalyzer +import com.crisiscleanup.core.database.dao.IncidentDao +import com.crisiscleanup.core.database.dao.IncidentDaoPlus +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity +import com.crisiscleanup.core.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.AppConfigData +import com.crisiscleanup.core.model.data.OrgData +import com.crisiscleanup.core.model.data.emptyAccountData +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IncidentClaimThresholdRepositoryTest { + @MockK + private lateinit var incidentDao: IncidentDao + + @MockK + private lateinit var incidentDaoPlus: IncidentDaoPlus + + @MockK + private lateinit var accountInfoDataSource: AccountInfoDataSource + + @MockK + private lateinit var workTypeAnalyzer: WorkTypeAnalyzer + + @MockK + private lateinit var appConfigRepository: AppConfigRepository + + @MockK + private lateinit var incidentSelector: IncidentSelector + + @MockK + private lateinit var logger: AppLogger + private val slotException = slot() + + private lateinit var claimThresholdRepository: IncidentClaimThresholdRepository + + @Before + fun setUp() { + MockKAnnotations.init(this) + + every { incidentSelector.incidentId } returns MutableStateFlow(34) + every { + accountInfoDataSource.accountData + } returns flowOf( + emptyAccountData.copy( + id = 84, + org = OrgData(77, "org"), + ), + ) + + every { logger.logException(capture(slotException)) } just runs + + claimThresholdRepository = CrisisCleanupIncidentClaimThresholdRepository( + incidentDao, + incidentDaoPlus, + accountInfoDataSource, + workTypeAnalyzer, + appConfigRepository, + incidentSelector, + logger, + ) + } + + private fun makeClaimThresholdEntity( + claimCount: Int = 0, + closeRatio: Float = 0f, + accountId: Long = 84, + incidentId: Long = 34, + ) = IncidentClaimThresholdEntity( + userId = accountId, + incidentId = incidentId, + userClaimCount = claimCount, + userCloseRatio = closeRatio, + ) + + @Test + fun nonPositiveClaimCount() = runTest { + for (i in -1..0) { + val isUnderClaimThreshold = claimThresholdRepository.isWithinClaimCloseThreshold(1, i) + assertTrue(isUnderClaimThreshold) + verify(exactly = 0) { + incidentDao.getIncidentClaimThreshold(any(), any()) + } + } + } + + @Test + fun skipUnsynced() = runTest { + claimThresholdRepository.onWorksiteCreated(354) + + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(10, 0.5f), + ) + + var dbCallCounter = 0 + val dbResults = listOf( + makeClaimThresholdEntity(0, 0f), + makeClaimThresholdEntity(10, 0.1f), + makeClaimThresholdEntity(11, 0.1f), + makeClaimThresholdEntity(9, 0.5f), + makeClaimThresholdEntity(9, 0.5001f), + makeClaimThresholdEntity(10, 0.5f), + makeClaimThresholdEntity(11, 0.5001f), + null, + ) + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } answers { + dbResults[dbCallCounter++] + } + + val expectedUnder = listOf( + true, + false, + false, + true, + true, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + + verify(exactly = 0) { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + any(), + any(), + any(), + ) + } + } + + @Test + fun unsyncedCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(10, 0.5f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(9, 4), + ClaimCloseCounts(10, 4), + ClaimCloseCounts(11, 5), + ClaimCloseCounts(9, 5), + ClaimCloseCounts(9, 6), + ClaimCloseCounts(10, 5), + ClaimCloseCounts(11, 6), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + true, + false, + false, + true, + true, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + + @Test + fun unsyncedNegativeClaimCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(30, 0.3f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(-9, 1), + ClaimCloseCounts(-10, 0), + ClaimCloseCounts(-10, 1), + ClaimCloseCounts(-11, 0), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + false, + false, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + + @Test + fun unsyncedNegativeCloseCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(16, 0.75f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(4, -2), + ClaimCloseCounts(4, -1), + ClaimCloseCounts(4, -2), + ClaimCloseCounts(4, -3), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + true, + true, + true, + false, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + + @Test + fun analyzerException() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(19, 0.49999f) + + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } throws (Exception("test-exception")) + + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(true, actual) + + assertEquals("test-exception", slotException.captured.message) + } +} diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json new file mode 100644 index 000000000..56c81a4a5 --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json @@ -0,0 +1,3370 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "14cfa1c49672e462fb73a8dffab22516", + "entities": [ + { + "tableName": "work_type_statuses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`status` TEXT NOT NULL, `name` TEXT NOT NULL, `list_order` INTEGER NOT NULL, `primary_state` TEXT NOT NULL, PRIMARY KEY(`status`))", + "fields": [ + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryState", + "columnName": "primary_state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "status" + ] + }, + "indices": [ + { + "name": "index_work_type_statuses_list_order", + "unique": false, + "columnNames": [ + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_type_statuses_list_order` ON `${TABLE_NAME}` (`list_order`)" + } + ] + }, + { + "tableName": "incidents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `start_at` INTEGER NOT NULL, `name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `case_label` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', `active_phone_number` TEXT DEFAULT '', `turn_on_release` INTEGER NOT NULL DEFAULT 0, `is_archived` INTEGER NOT NULL DEFAULT 0, `ignore_claiming_thresholds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startAt", + "columnName": "start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "caseLabel", + "columnName": "case_label", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "activePhoneNumber", + "columnName": "active_phone_number", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "turnOnRelease", + "columnName": "turn_on_release", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "is_archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ignoreClaimingThresholds", + "columnName": "ignore_claiming_thresholds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_newest_to_oldest_incidents", + "unique": false, + "columnNames": [ + "start_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_newest_to_oldest_incidents` ON `${TABLE_NAME}` (`start_at` DESC)" + } + ] + }, + { + "tableName": "incident_locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `location` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "incident_to_incident_location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `incident_location_id` INTEGER NOT NULL, PRIMARY KEY(`incident_id`, `incident_location_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`incident_location_id`) REFERENCES `incident_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentLocationId", + "columnName": "incident_location_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "incident_location_id" + ] + }, + "indices": [ + { + "name": "idx_incident_location_to_incident", + "unique": false, + "columnNames": [ + "incident_location_id", + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_incident_location_to_incident` ON `${TABLE_NAME}` (`incident_location_id`, `incident_id`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_form_fields", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `label` TEXT NOT NULL, `html_type` TEXT NOT NULL, `data_group` TEXT NOT NULL, `help` TEXT DEFAULT '', `placeholder` TEXT DEFAULT '', `read_only_break_glass` INTEGER NOT NULL, `values_default_json` TEXT DEFAULT '', `is_checkbox_default_true` INTEGER DEFAULT 0, `order_label` INTEGER NOT NULL DEFAULT -1, `validation` TEXT DEFAULT '', `recur_default` TEXT DEFAULT '0', `values_json` TEXT DEFAULT '', `is_required` INTEGER DEFAULT 0, `is_read_only` INTEGER DEFAULT 0, `list_order` INTEGER NOT NULL, `is_invalidated` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `field_parent_key` TEXT DEFAULT '', `parent_key` TEXT NOT NULL DEFAULT '', `selected_toggle_work_type` TEXT DEFAULT '', PRIMARY KEY(`incident_id`, `parent_key`, `field_key`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlType", + "columnName": "html_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataGroup", + "columnName": "data_group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "help", + "columnName": "help", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "placeholder", + "columnName": "placeholder", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "readOnlyBreakGlass", + "columnName": "read_only_break_glass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesDefaultJson", + "columnName": "values_default_json", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "isCheckboxDefaultTrue", + "columnName": "is_checkbox_default_true", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "orderLabel", + "columnName": "order_label", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "validation", + "columnName": "validation", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "recurDefault", + "columnName": "recur_default", + "affinity": "TEXT", + "defaultValue": "'0'" + }, + { + "fieldPath": "valuesJson", + "columnName": "values_json", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInvalidated", + "columnName": "is_invalidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldParentKey", + "columnName": "field_parent_key", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "parentKeyNonNull", + "columnName": "parent_key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "selectToggleWorkType", + "columnName": "selected_toggle_work_type", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "parent_key", + "field_key" + ] + }, + "indices": [ + { + "name": "index_incident_form_fields_data_group_parent_key_list_order", + "unique": false, + "columnNames": [ + "data_group", + "parent_key", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_form_fields_data_group_parent_key_list_order` ON `${TABLE_NAME}` (`data_group`, `parent_key`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `shape_type` TEXT NOT NULL DEFAULT '', `coordinates` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shapeType", + "columnName": "shape_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "coordinates", + "columnName": "coordinates", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "worksite_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + } + }, + { + "tableName": "worksites_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksites_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_worksites_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `address` TEXT NOT NULL, `auto_contact_frequency_t` TEXT, `case_number` TEXT NOT NULL, `case_number_order` INTEGER NOT NULL DEFAULT 0, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `created_at` INTEGER, `email` TEXT DEFAULT '', `favorite_id` INTEGER, `key_work_type_type` TEXT NOT NULL DEFAULT '', `key_work_type_org` INTEGER, `key_work_type_status` TEXT NOT NULL DEFAULT '', `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT, `phone1_notes` TEXT DEFAULT '', `phone2` TEXT DEFAULT '', `phone2_notes` TEXT DEFAULT '', `phone_search` TEXT DEFAULT '', `plus_code` TEXT DEFAULT '', `postal_code` TEXT NOT NULL, `reported_by` INTEGER, `state` TEXT NOT NULL, `svi` REAL, `what3Words` TEXT DEFAULT '', `updated_at` INTEGER NOT NULL, `network_photo_count` INTEGER DEFAULT 0, `is_local_favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoContactFrequencyT", + "columnName": "auto_contact_frequency_t", + "affinity": "TEXT" + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumberOrder", + "columnName": "case_number_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "favoriteId", + "columnName": "favorite_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "keyWorkTypeType", + "columnName": "key_work_type_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "keyWorkTypeOrgClaim", + "columnName": "key_work_type_org", + "affinity": "INTEGER" + }, + { + "fieldPath": "keyWorkTypeStatus", + "columnName": "key_work_type_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT" + }, + { + "fieldPath": "phone1Notes", + "columnName": "phone1_notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phone2Notes", + "columnName": "phone2_notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phoneSearch", + "columnName": "phone_search", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "plusCode", + "columnName": "plus_code", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "postalCode", + "columnName": "postal_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportedBy", + "columnName": "reported_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "svi", + "columnName": "svi", + "affinity": "REAL" + }, + { + "fieldPath": "what3Words", + "columnName": "what3Words", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoCount", + "columnName": "network_photo_count", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "isLocalFavorite", + "columnName": "is_local_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksites_incident_id_latitude_longitude", + "unique": false, + "columnNames": [ + "incident_id", + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_latitude_longitude` ON `${TABLE_NAME}` (`incident_id`, `latitude`, `longitude`)" + }, + { + "name": "index_worksites_incident_id_longitude_latitude", + "unique": false, + "columnNames": [ + "incident_id", + "longitude", + "latitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_longitude_latitude` ON `${TABLE_NAME}` (`incident_id`, `longitude`, `latitude`)" + }, + { + "name": "index_worksites_incident_id_svi", + "unique": false, + "columnNames": [ + "incident_id", + "svi" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_svi` ON `${TABLE_NAME}` (`incident_id`, `svi`)" + }, + { + "name": "index_worksites_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id`, `updated_at`)" + }, + { + "name": "index_worksites_incident_id_created_at", + "unique": false, + "columnNames": [ + "incident_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_created_at` ON `${TABLE_NAME}` (`incident_id`, `created_at`)" + }, + { + "name": "index_worksites_incident_id_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_name_county_city_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "name", + "county", + "city", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_name_county_city_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `name`, `county`, `city`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_city_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "city", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_city_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `city`, `name`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_county_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "county", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_county_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `county`, `name`, `case_number_order`, `case_number`)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "work_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER, `claimed_by` INTEGER, `next_recur_at` INTEGER, `phase` INTEGER, `recur` TEXT, `status` TEXT NOT NULL, `work_type` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "orgClaim", + "columnName": "claimed_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextRecurAt", + "columnName": "next_recur_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "INTEGER" + }, + { + "fieldPath": "recur", + "columnName": "recur", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_work_type", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_work_type` ON `${TABLE_NAME}` (`worksite_id`, `work_type`)" + }, + { + "name": "index_work_types_worksite_id_network_id", + "unique": false, + "columnNames": [ + "worksite_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_worksite_id_network_id` ON `${TABLE_NAME}` (`worksite_id`, `network_id`)" + }, + { + "name": "index_work_types_status_worksite_id", + "unique": false, + "columnNames": [ + "status", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_status_worksite_id` ON `${TABLE_NAME}` (`status`, `worksite_id`)" + }, + { + "name": "index_work_types_claimed_by_worksite_id", + "unique": false, + "columnNames": [ + "claimed_by", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_claimed_by_worksite_id` ON `${TABLE_NAME}` (`claimed_by`, `worksite_id`)" + }, + { + "name": "index_work_types_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_network_id` ON `${TABLE_NAME}` (`network_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_form_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `is_bool_value` INTEGER NOT NULL, `value_string` TEXT NOT NULL, `value_bool` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `field_key`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isBoolValue", + "columnName": "is_bool_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valueString", + "columnName": "value_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueBool", + "columnName": "value_bool", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "field_key" + ] + }, + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_flags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `action` TEXT, `created_at` INTEGER NOT NULL, `is_high_priority` INTEGER DEFAULT 0, `notes` TEXT DEFAULT '', `reason_t` TEXT NOT NULL, `requested_action` TEXT DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHighPriority", + "columnName": "is_high_priority", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "reasonT", + "columnName": "reason_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedAction", + "columnName": "requested_action", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_flag", + "unique": true, + "columnNames": [ + "worksite_id", + "reason_t" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_flag` ON `${TABLE_NAME}` (`worksite_id`, `reason_t`)" + }, + { + "name": "index_worksite_flags_reason_t", + "unique": false, + "columnNames": [ + "reason_t" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_flags_reason_t` ON `${TABLE_NAME}` (`reason_t`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_global_uuid` TEXT NOT NULL DEFAULT '', `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_survivor` INTEGER NOT NULL, `note` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSurvivor", + "columnName": "is_survivor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_note", + "unique": true, + "columnNames": [ + "worksite_id", + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_note` ON `${TABLE_NAME}` (`worksite_id`, `network_id`, `local_global_uuid`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "language_translations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `translations_json` TEXT, `synced_at` INTEGER DEFAULT 0, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationsJson", + "columnName": "translations_json", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "sync_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `log_time` INTEGER NOT NULL, `log_type` TEXT NOT NULL DEFAULT '', `message` TEXT NOT NULL, `details` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logTime", + "columnName": "log_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logType", + "columnName": "log_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "details", + "columnName": "details", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_logs_log_time", + "unique": false, + "columnNames": [ + "log_time" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_logs_log_time` ON `${TABLE_NAME}` (`log_time` DESC)" + } + ] + }, + { + "tableName": "worksite_changes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_version` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `change_model_version` INTEGER NOT NULL, `change_data` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `save_attempt` INTEGER NOT NULL DEFAULT 0, `archive_action` TEXT NOT NULL, `save_attempt_at` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`worksite_id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "changeModelVersion", + "columnName": "change_model_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changeData", + "columnName": "change_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveAttempt", + "columnName": "save_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "archiveAction", + "columnName": "archive_action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveAttemptAt", + "columnName": "save_attempt_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_changes_worksite_id_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_at`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt` ON `${TABLE_NAME}` (`worksite_id`, `save_attempt`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt_at_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt_at", + "created_at" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt_at_created_at` ON `${TABLE_NAME}` (`worksite_id` ASC, `save_attempt_at` ASC, `created_at` DESC)" + }, + { + "name": "index_worksite_changes_organization_id_worksite_id_created_at", + "unique": false, + "columnNames": [ + "organization_id", + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_organization_id_worksite_id_created_at` ON `${TABLE_NAME}` (`organization_id`, `worksite_id`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `primary_location` INTEGER, `secondary_location` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryLocation", + "columnName": "primary_location", + "affinity": "INTEGER" + }, + { + "fieldPath": "secondaryLocation", + "columnName": "secondary_location", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "person_contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `email` TEXT NOT NULL, `mobile` TEXT NOT NULL, `profilePictureUri` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "first_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "last_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mobile", + "columnName": "mobile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUri", + "columnName": "profilePictureUri", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "organization_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organization_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`organization_id`, `contact_id`), FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organization_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_organization", + "unique": false, + "columnNames": [ + "contact_id", + "organization_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_organization` ON `${TABLE_NAME}` (`contact_id`, `organization_id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "organization_to_affiliate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `affiliate_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `affiliate_id`), FOREIGN KEY(`id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affiliateId", + "columnName": "affiliate_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "affiliate_id" + ] + }, + "indices": [ + { + "name": "index_organization_to_affiliate_affiliate_id_id", + "unique": false, + "columnNames": [ + "affiliate_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_organization_to_affiliate_affiliate_id_id` ON `${TABLE_NAME}` (`affiliate_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organization_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `target_count` INTEGER NOT NULL, `successful_sync` INTEGER, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + } + }, + { + "tableName": "recent_worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `viewed_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewed_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recent_worksites_incident_id_viewed_at", + "unique": false, + "columnNames": [ + "incident_id", + "viewed_at" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_incident_id_viewed_at` ON `${TABLE_NAME}` (`incident_id` ASC, `viewed_at` DESC)" + }, + { + "name": "index_recent_worksites_viewed_at", + "unique": false, + "columnNames": [ + "viewed_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_viewed_at` ON `${TABLE_NAME}` (`viewed_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_work_type_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `work_type` TEXT NOT NULL, `reason` TEXT NOT NULL, `by_org` INTEGER NOT NULL, `to_org` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `approved_at` INTEGER, `rejected_at` INTEGER, `approved_rejected_reason` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "byOrg", + "columnName": "by_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toOrg", + "columnName": "to_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "approvedAt", + "columnName": "approved_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvedRejectedReason", + "columnName": "approved_rejected_reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_work_type_requests_worksite_id_work_type_by_org", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type", + "by_org" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_work_type_by_org` ON `${TABLE_NAME}` (`worksite_id`, `work_type`, `by_org`)" + }, + { + "name": "index_worksite_work_type_requests_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksite_work_type_requests_worksite_id_by_org", + "unique": false, + "columnNames": [ + "worksite_id", + "by_org" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_by_org` ON `${TABLE_NAME}` (`worksite_id`, `by_org`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `file_id` INTEGER NOT NULL DEFAULT 0, `file_type_t` TEXT NOT NULL, `full_url` TEXT, `large_thumbnail_url` TEXT, `mime_content_type` TEXT NOT NULL, `small_thumbnail_url` TEXT, `tag` TEXT, `title` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "file_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fileTypeT", + "columnName": "file_type_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullUrl", + "columnName": "full_url", + "affinity": "TEXT" + }, + { + "fieldPath": "largeThumbnailUrl", + "columnName": "large_thumbnail_url", + "affinity": "TEXT" + }, + { + "fieldPath": "mimeContentType", + "columnName": "mime_content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallThumbnailUrl", + "columnName": "small_thumbnail_url", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "worksite_to_network_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `network_file_id` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `network_file_id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`network_file_id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkFileId", + "columnName": "network_file_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "network_file_id" + ] + }, + "indices": [ + { + "name": "index_worksite_to_network_file_network_file_id_worksite_id", + "unique": false, + "columnNames": [ + "network_file_id", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_to_network_file_network_file_id_worksite_id` ON `${TABLE_NAME}` (`network_file_id`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "network_file_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_file_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, `rotate_degrees` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_network_file_local_images_is_deleted", + "unique": false, + "columnNames": [ + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_network_file_local_images_is_deleted` ON `${TABLE_NAME}` (`is_deleted`)" + } + ], + "foreignKeys": [ + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `worksite_id` INTEGER NOT NULL, `local_document_id` TEXT NOT NULL, `uri` TEXT NOT NULL, `tag` TEXT NOT NULL, `rotate_degrees` INTEGER NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "local_document_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_local_images_worksite_id_local_document_id", + "unique": true, + "columnNames": [ + "worksite_id", + "local_document_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_local_images_worksite_id_local_document_id` ON `${TABLE_NAME}` (`worksite_id`, `local_document_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_worksites_full_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `synced_at` INTEGER, `center_my_location` INTEGER NOT NULL, `center_latitude` REAL NOT NULL DEFAULT 999, `center_longitude` REAL NOT NULL DEFAULT 999, `query_area_radius` REAL NOT NULL, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "isMyLocationCentered", + "columnName": "center_my_location", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "center_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "longitude", + "columnName": "center_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "radius", + "columnName": "query_area_radius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "tableName": "incident_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', content=`incidents`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incidents", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_UPDATE BEFORE UPDATE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_DELETE BEFORE DELETE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_UPDATE AFTER UPDATE ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_INSERT AFTER INSERT ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END" + ] + }, + { + "tableName": "incident_organization_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, content=`incident_organizations`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incident_organizations", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_UPDATE BEFORE UPDATE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_DELETE BEFORE DELETE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_UPDATE AFTER UPDATE ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_INSERT AFTER INSERT ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "case_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `created_by` INTEGER NOT NULL, `event_key` TEXT NOT NULL, `past_tense_t` TEXT NOT NULL, `actor_location_name` TEXT NOT NULL, `recipient_location_name` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventKey", + "columnName": "event_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pastTenseT", + "columnName": "past_tense_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorLocationName", + "columnName": "actor_location_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientLocationName", + "columnName": "recipient_location_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_case_history_events_worksite_id_created_by_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_by", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_case_history_events_worksite_id_created_by_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_by`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "case_history_event_attrs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_name` TEXT NOT NULL, `patient_case_number` TEXT, `patient_id` INTEGER NOT NULL, `patient_label_t` TEXT, `patient_location_name` TEXT, `patient_name_t` TEXT, `patient_reason_t` TEXT, `patient_status_name_t` TEXT, `recipient_case_number` TEXT, `recipient_id` INTEGER, `recipient_name` TEXT, `recipient_name_t` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `case_history_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentName", + "columnName": "incident_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCaseNumber", + "columnName": "patient_case_number", + "affinity": "TEXT" + }, + { + "fieldPath": "patientId", + "columnName": "patient_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientLabelT", + "columnName": "patient_label_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientLocationName", + "columnName": "patient_location_name", + "affinity": "TEXT" + }, + { + "fieldPath": "patientNameT", + "columnName": "patient_name_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientReasonT", + "columnName": "patient_reason_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientStatusNameT", + "columnName": "patient_status_name_t", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientCaseNumber", + "columnName": "recipient_case_number", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientId", + "columnName": "recipient_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "recipientName", + "columnName": "recipient_name", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientNameT", + "columnName": "recipient_name_t", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "case_history_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "person_to_organization", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `organization_id`), FOREIGN KEY(`id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "organization_id" + ] + }, + "indices": [ + { + "name": "index_person_to_organization_organization_id_id", + "unique": false, + "columnNames": [ + "organization_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_person_to_organization_organization_id_id` ON `${TABLE_NAME}` (`organization_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_text_fts_c", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`address` TEXT NOT NULL, `case_number` TEXT NOT NULL, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `email` TEXT NOT NULL, `name` TEXT NOT NULL, `phone_search` TEXT NOT NULL DEFAULT '', content=`worksites`)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneSearch", + "columnName": "phone_search", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "worksites", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_BEFORE_UPDATE BEFORE UPDATE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_c` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_BEFORE_DELETE BEFORE DELETE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_c` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_AFTER_UPDATE AFTER UPDATE ON `worksites` BEGIN INSERT INTO `worksite_text_fts_c`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone_search`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone_search`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_AFTER_INSERT AFTER INSERT ON `worksites` BEGIN INSERT INTO `worksite_text_fts_c`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone_search`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone_search`); END" + ] + }, + { + "tableName": "incident_worksites_secondary_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "tableName": "lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `local_global_uuid` TEXT NOT NULL DEFAULT '', `created_by` INTEGER, `updated_by` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `parent` INTEGER, `name` TEXT NOT NULL, `description` TEXT, `list_order` INTEGER, `tags` TEXT, `model` TEXT NOT NULL, `object_ids` TEXT NOT NULL DEFAULT '', `shared` TEXT NOT NULL, `permissions` TEXT NOT NULL, `incident_id` INTEGER, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedBy", + "columnName": "updated_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectIds", + "columnName": "object_ids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "shared", + "columnName": "shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_lists_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_lists_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_lists_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id` DESC, `updated_at` DESC)" + }, + { + "name": "index_lists_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_updated_at` ON `${TABLE_NAME}` (`updated_at` DESC)" + }, + { + "name": "index_lists_model_updated_at", + "unique": false, + "columnNames": [ + "model", + "updated_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_model_updated_at` ON `${TABLE_NAME}` (`model` DESC, `updated_at` DESC)" + }, + { + "name": "index_lists_parent_list_order", + "unique": false, + "columnNames": [ + "parent", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_parent_list_order` ON `${TABLE_NAME}` (`parent`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "teams_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_teams_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teams_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_teams_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_teams_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "teams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `notes` TEXT NOT NULL, `color` TEXT NOT NULL, `case_count` INTEGER NOT NULL, `case_complete_count` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `teams_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseCount", + "columnName": "case_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completeCount", + "columnName": "case_complete_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_teams_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_teams_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_teams_incident_id_name", + "unique": false, + "columnNames": [ + "incident_id", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_incident_id_name` ON `${TABLE_NAME}` (`incident_id`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "teams_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "team_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`team_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`team_id`, `contact_id`), FOREIGN KEY(`team_id`) REFERENCES `teams`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "teamId", + "columnName": "team_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "team_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_team", + "unique": false, + "columnNames": [ + "contact_id", + "team_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_team` ON `${TABLE_NAME}` (`contact_id`, `team_id`)" + } + ], + "foreignKeys": [ + { + "table": "teams", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "team_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_data_sync_parameters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `updated_before` INTEGER NOT NULL, `updated_after` INTEGER NOT NULL, `full_updated_before` INTEGER NOT NULL, `full_updated_after` INTEGER NOT NULL, `bounded_region` TEXT NOT NULL, `bounded_synced_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedBefore", + "columnName": "updated_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAfter", + "columnName": "updated_after", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalUpdatedBefore", + "columnName": "full_updated_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalUpdatedAfter", + "columnName": "full_updated_after", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boundedRegion", + "columnName": "bounded_region", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "boundedSyncedAt", + "columnName": "bounded_synced_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_claim_thresholds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `user_claim_count` INTEGER NOT NULL, `user_close_ratio` REAL NOT NULL, PRIMARY KEY(`user_id`, `incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userClaimCount", + "columnName": "user_claim_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userCloseRatio", + "columnName": "user_close_ratio", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id", + "incident_id" + ] + }, + "indices": [ + { + "name": "index_incident_claim_thresholds_incident_id", + "unique": false, + "columnNames": [ + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_claim_thresholds_incident_id` ON `${TABLE_NAME}` (`incident_id`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14cfa1c49672e462fb73a8dffab22516')" + ] + } +} \ No newline at end of file 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 f9bcabb07..0ed021e05 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 @@ -11,6 +11,7 @@ import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity import com.crisiscleanup.core.database.model.CaseHistoryEventAttrEntity import com.crisiscleanup.core.database.model.CaseHistoryEventEntity +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentDataSyncParametersEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity @@ -95,6 +96,7 @@ import kotlinx.datetime.Instant TeamEntity::class, TeamMemberCrossRef::class, IncidentDataSyncParametersEntity::class, + IncidentClaimThresholdEntity::class, ], version = 1, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt index c2afba1d3..e5f78c0d9 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt @@ -39,6 +39,7 @@ import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity import com.crisiscleanup.core.database.model.CaseHistoryEventAttrEntity import com.crisiscleanup.core.database.model.CaseHistoryEventEntity +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentDataSyncParametersEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity @@ -118,8 +119,9 @@ import com.crisiscleanup.core.database.util.InstantConverter TeamEntity::class, TeamMemberCrossRef::class, IncidentDataSyncParametersEntity::class, + IncidentClaimThresholdEntity::class, ], - version = 47, + version = 48, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -167,6 +169,7 @@ import com.crisiscleanup.core.database.util.InstantConverter AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46, spec = Schema45To46::class), AutoMigration(from = 46, to = 47), + AutoMigration(from = 47, to = 48), ], exportSchema = true, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index 52caa4301..152c6701a 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import com.crisiscleanup.core.database.dao.fts.PopulatedIncidentIdNameMatchInfo +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef @@ -96,12 +97,32 @@ interface IncidentDao { validFieldKeys: Set, ) + @Transaction + @Query( + """ + DELETE FROM incident_claim_thresholds + WHERE user_id=:accountId AND incident_id NOT IN(:incidentIds) + """, + ) + suspend fun deleteUnspecifiedClaimThresholds(accountId: Long, incidentIds: Collection) + @Upsert - suspend fun upsertFormFields(formFields: Collection) + suspend fun upsertIncidentClaimThresholds(claimThresholds: List) @Transaction - @Query("SELECT name FROM incidents ORDER BY RANDOM() LIMIT 1") - fun getRandomIncidentName(): String? + @Query( + """ + SELECT * FROM incident_claim_thresholds + WHERE user_id=:accountId AND incident_id=:incidentId + """, + ) + fun getIncidentClaimThreshold( + accountId: Long, + incidentId: Long, + ): IncidentClaimThresholdEntity? + + @Upsert + suspend fun upsertFormFields(formFields: Collection) @Transaction @Query("INSERT INTO incident_fts(incident_fts) VALUES ('rebuild')") diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt index b1a631e5d..70df8e2d9 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt @@ -2,10 +2,12 @@ package com.crisiscleanup.core.database.dao import androidx.room.withTransaction import com.crisiscleanup.core.database.CrisisCleanupDatabase +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef import com.crisiscleanup.core.database.model.IncidentLocationEntity +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import javax.inject.Inject class IncidentDaoPlus @Inject constructor( @@ -54,4 +56,23 @@ class IncidentDaoPlus @Inject constructor( incidents.forEach { updateFormFields(it.first, it.second) } } } + + suspend fun saveIncidentThresholds( + accountId: Long, + incidentThresholds: List, + ) = + db.withTransaction { + val incidentDao = db.incidentDao() + val incidentIds = incidentThresholds.map(IncidentClaimThreshold::incidentId) + incidentDao.deleteUnspecifiedClaimThresholds(accountId, incidentIds) + val entities = incidentThresholds.map { + IncidentClaimThresholdEntity( + userId = accountId, + incidentId = it.incidentId, + userClaimCount = it.claimedCount, + userCloseRatio = it.closedRatio, + ) + } + incidentDao.upsertIncidentClaimThresholds(entities) + } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt index 7363a798f..38dd96f1a 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt @@ -70,10 +70,6 @@ interface IncidentOrganizationDao { @Query("SELECT primary_location, secondary_location FROM incident_organizations WHERE id=:orgId") fun streamLocationIds(orgId: Long): Flow - @Transaction - @Query("SELECT name FROM incident_organizations ORDER BY RANDOM() LIMIT 1") - fun getRandomOrganizationName(): String? - @Transaction @Query("INSERT INTO incident_organization_fts(incident_organization_fts) VALUES ('rebuild')") fun rebuildOrganizationFts() diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt index 35fab6755..16001d413 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt @@ -21,6 +21,16 @@ interface WorksiteChangeDao { @Query("SELECT * FROM worksite_changes WHERE worksite_id=:worksiteId ORDER BY created_at ASC") fun getOrdered(worksiteId: Long): List + @Transaction + @Query( + """ + SELECT * FROM worksite_changes + WHERE organization_id=:orgId + ORDER BY worksite_id, created_at + """, + ) + fun getOrgChanges(orgId: Long): List + @Transaction @Query( """ 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 f9cc23ac3..303203907 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 @@ -641,10 +641,6 @@ interface WorksiteDao { @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? - @Transaction @Query("INSERT INTO worksite_text_fts_c(worksite_text_fts_c) VALUES ('rebuild')") fun rebuildWorksiteTextFts() diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt index f8311825e..717c40d61 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt @@ -11,7 +11,6 @@ import com.crisiscleanup.core.database.model.PopulatedIncidentMatch import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.IncidentIdNameType @@ -63,20 +62,7 @@ data class PopulatedIncidentIdNameMatchInfo( } } -suspend fun IncidentDaoPlus.rebuildIncidentFts(force: Boolean = false) = db.withTransaction { - with(db.incidentDao()) { - var rebuild = force - if (!force) { - getRandomIncidentName()?.let { incidentName -> - val ftsMatch = matchIncidentTokens(incidentName.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildIncidentFts() - } - } -} +fun IncidentDaoPlus.rebuildIncidentFts() = db.incidentDao().rebuildIncidentFts() suspend fun IncidentDaoPlus.getMatchingIncidents(q: String): List = coroutineScope { diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt index 5c8e12d07..990cde02a 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt @@ -9,7 +9,6 @@ import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.OrganizationIdName @@ -57,21 +56,8 @@ data class PopulatedOrganizationIdNameMatchInfo( } } -suspend fun IncidentOrganizationDaoPlus.rebuildOrganizationFts(force: Boolean = false) = - db.withTransaction { - with(db.incidentOrganizationDao()) { - var rebuild = force - if (!force) { - getRandomOrganizationName()?.let { orgName -> - val ftsMatch = matchOrganizationName(orgName.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildOrganizationFts() - } - } - } +fun IncidentOrganizationDaoPlus.rebuildOrganizationFts() = + db.incidentOrganizationDao().rebuildOrganizationFts() suspend fun IncidentOrganizationDaoPlus.getMatchingOrganizations(q: String): List = coroutineScope { diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt index b01293ccc..f23cec209 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt @@ -9,7 +9,6 @@ import com.crisiscleanup.core.database.dao.WorksiteDaoPlus import com.crisiscleanup.core.database.model.WorksiteEntity import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.WorkType @@ -72,21 +71,7 @@ data class PopulatedWorksiteTextMatchInfo( } } -suspend fun WorksiteDaoPlus.rebuildWorksiteTextFts(force: Boolean = false) = - db.withTransaction { - with(db.worksiteDao()) { - var rebuild = force - if (!force) { - getRandomWorksiteCaseNumber()?.let { caseNumber -> - val ftsMatch = matchSingleWorksiteTextTokens(caseNumber.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildWorksiteTextFts() - } - } - } +fun WorksiteDaoPlus.rebuildWorksiteTextFts() = db.worksiteDao().rebuildWorksiteTextFts() suspend fun WorksiteDaoPlus.getMatchingWorksites( incidentId: Long, diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt new file mode 100644 index 000000000..ab45405e2 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt @@ -0,0 +1,34 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index + +@Entity( + "incident_claim_thresholds", + primaryKeys = ["user_id", "incident_id"], + foreignKeys = [ + ForeignKey( + entity = IncidentEntity::class, + parentColumns = ["id"], + childColumns = ["incident_id"], + onDelete = ForeignKey.CASCADE, + ), + ], + indices = [ + Index( + value = ["incident_id"], + ), + ], +) +data class IncidentClaimThresholdEntity( + @ColumnInfo("user_id") + val userId: Long, + @ColumnInfo("incident_id") + val incidentId: Long, + @ColumnInfo("user_claim_count") + val userClaimCount: Int, + @ColumnInfo("user_close_ratio") + val userCloseRatio: Float, +) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt index 8a167465e..e51b1a163 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt @@ -39,6 +39,8 @@ data class IncidentEntity( val turnOnRelease: Boolean = false, @ColumnInfo("is_archived", defaultValue = "0") val isArchived: Boolean = false, + @ColumnInfo("ignore_claiming_thresholds", defaultValue = "0") + val ignoreClaimingThresholds: Boolean = false, ) @Entity("incident_locations") diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt index c70f8eb3e..f9f50b789 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt @@ -26,6 +26,7 @@ import kotlinx.datetime.Instant value = ["worksite_id", "save_attempt_at", "created_at"], orders = [Order.ASC, Order.ASC, Order.DESC], ), + Index(value = ["organization_id", "worksite_id", "created_at"]), ], ) data class WorksiteChangeEntity( diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto new file mode 100644 index 000000000..6b77e90fc --- /dev/null +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +option java_package = "com.crisiscleanup.core.datastore"; +option java_multiple_files = true; + +message AppConfig { + int32 claimed_work_type_count_threshold = 1; + float claimed_work_type_closed_ratio_threshold = 2; +} diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto new file mode 100644 index 000000000..6e4daea0e --- /dev/null +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +option java_package = "com.crisiscleanup.core.datastore"; +option java_multiple_files = true; + +message AppMaintenance { + int64 fts_rebuild_version = 1; +} 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 e0580b4ea..9ee57f48a 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 @@ -22,7 +22,7 @@ message UserPreferences { int64 selected_incident_id = 4; - // Deprecated since OAuth and other auth options was added + // Deprecated since OAuth and other auth options are available int32 save_credentials_prompt_count = 5 [deprecated = true]; bool disable_save_credentials_prompt = 6 [deprecated = true]; @@ -43,4 +43,6 @@ message UserPreferences { bool is_work_screen_table_view = 15; bool sync_media_immediate = 16; + + bool is_map_satellite_view = 17; } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt new file mode 100644 index 000000000..568308ebd --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt @@ -0,0 +1,27 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.DataStore +import com.crisiscleanup.core.model.data.AppConfigData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppConfigDataSource @Inject constructor( + private val appConfig: DataStore, +) { + val appConfigData = appConfig.data + .map { + AppConfigData( + claimCountThreshold = it.claimedWorkTypeCountThreshold, + closedClaimRatioThreshold = it.claimedWorkTypeClosedRatioThreshold, + ) + } + + suspend fun setClaimThresholds(count: Int, ratio: Float) { + appConfig.updateData { + it.copy { + claimedWorkTypeCountThreshold = count + claimedWorkTypeClosedRatioThreshold = ratio + } + } + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt new file mode 100644 index 000000000..831bdc72c --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt @@ -0,0 +1,25 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class AppConfigSerializer @Inject constructor() : Serializer { + override val defaultValue: AppConfig = AppConfig.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): AppConfig = + try { + // readFrom is already called on the data store background thread + AppConfig.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: AppConfig, output: OutputStream) { + // writeTo is already called on the data store background thread + t.writeTo(output) + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt new file mode 100644 index 000000000..a52c3b36f --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt @@ -0,0 +1,23 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.DataStore +import com.crisiscleanup.core.model.data.AppMaintenanceData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppMaintenanceDataSource @Inject constructor( + private val appMaintenance: DataStore, +) { + val maintenanceData = appMaintenance.data + .map { + AppMaintenanceData( + ftsRebuildVersion = it.ftsRebuildVersion, + ) + } + + suspend fun setFtsRebuildVersion(version: Long) { + appMaintenance.updateData { + it.copy { ftsRebuildVersion = version } + } + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt new file mode 100644 index 000000000..bdebe1ea1 --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt @@ -0,0 +1,25 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class AppMaintenanceSerializer @Inject constructor() : Serializer { + override val defaultValue: AppMaintenance = AppMaintenance.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): AppMaintenance = + try { + // readFrom is already called on the data store background thread + AppMaintenance.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: AppMaintenance, output: OutputStream) { + // writeTo is already called on the data store background thread + t.writeTo(output) + } +} 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 9a167752b..1b74ebcad 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 @@ -54,6 +54,8 @@ class LocalAppPreferencesDataSource @Inject constructor( isWorkScreenTableView = it.isWorkScreenTableView, isSyncMediaImmediate = it.syncMediaImmediate, + + isMapSatelliteView = it.isMapSatelliteView, ) } @@ -163,6 +165,12 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { syncMediaImmediate = syncImmediate } } } + + suspend fun saveMapSatelliteView(isSatellite: Boolean) { + userPreferences.updateData { + it.copy { isMapSatelliteView = isSatellite } + } + } } private fun IncidentMapBoundsProto.asExternalModel() = IncidentCoordinateBounds( diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt index 9ae39a218..8a4e265db 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt @@ -6,6 +6,8 @@ import androidx.datastore.dataStoreFile import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.datastore.AccountInfoProtoSerializer +import com.crisiscleanup.core.datastore.AppConfigSerializer +import com.crisiscleanup.core.datastore.AppMaintenanceSerializer import com.crisiscleanup.core.datastore.AppMetricsSerializer import com.crisiscleanup.core.datastore.CasesFiltersProtoSerializer import com.crisiscleanup.core.datastore.IncidentCachePreferencesSerializer @@ -87,4 +89,30 @@ object DataStoreModule { ) { context.dataStoreFile("incident_cache_preferences.pb") } + + @Provides + @Singleton + fun providesAppMaintenanceDataStore( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + serializer: AppMaintenanceSerializer, + ) = DataStoreFactory.create( + serializer = serializer, + scope = CoroutineScope(ioDispatcher + SupervisorJob()), + ) { + context.dataStoreFile("app_maintenance.pb") + } + + @Provides + @Singleton + fun providesAppConfigDataStore( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + serializer: AppConfigSerializer, + ) = DataStoreFactory.create( + serializer = serializer, + scope = CoroutineScope(ioDispatcher + SupervisorJob()), + ) { + context.dataStoreFile("app_config.pb") + } } diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt new file mode 100644 index 000000000..f41b5ba70 --- /dev/null +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import kotlin.test.assertEquals + +class AppConfigSerializerTest { + private val serializer = AppConfigSerializer() + + @Test + fun defaultAppConfig_isEmpty() { + assertEquals( + appConfig { + // Default value + }, + serializer.defaultValue, + ) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidAppConfig_throwsCorruptionException() = runTest { + serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt new file mode 100644 index 000000000..949aed07d --- /dev/null +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import kotlin.test.assertEquals + +class AppMaintenanceSerializerTest { + private val serializer = AppMaintenanceSerializer() + + @Test + fun defaultAppMaintenance_isEmpty() { + assertEquals( + appMaintenance { + // Default value + }, + serializer.defaultValue, + ) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidAppMaintenance_throwsCorruptionException() = runTest { + serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt new file mode 100644 index 000000000..274956339 --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt @@ -0,0 +1,44 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.disabledAlpha + +@Composable +fun BoxScope.MapViewToggleButton( + isSatelliteView: Boolean, + onToggle: (Boolean) -> Unit, + // TODO Common dimensions + padding: Dp = 8.dp, +) { + val mapViewIcon = if (isSatelliteView) { + CrisisCleanupIcons.NormalMap + } else { + CrisisCleanupIcons.SatelliteMap + } + val actionDescription = + LocalAppTranslator.current("~~Toggle map normal/satellite view") + CrisisCleanupIconButton( + Modifier + .padding(padding) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.disabledAlpha()) + .align(Alignment.TopEnd), + imageVector = mapViewIcon, + contentDescription = actionDescription, + onClick = { + onToggle(!isSatelliteView) + }, + ) +} diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt index fa06099b0..da42ea1cf 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt @@ -9,6 +9,7 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.maps.android.PolyUtil import com.google.maps.android.SphericalUtil +import kotlin.math.abs import kotlin.math.sqrt fun Pair.toLatLng() = LatLng(first, second) @@ -109,7 +110,7 @@ internal fun Collection.toBounds(): IncidentBounds { val polyDelta = polyPoint.subtract(incidentCentroid) val polyDeltaNorm = polyDelta.normalizeOrSelf() if (polyDelta != polyDeltaNorm && - polyDeltaNorm.latitude * deltaNorm.latitude + polyDeltaNorm.longitude * deltaNorm.longitude > 0.9 + abs(polyDeltaNorm.latitude * deltaNorm.latitude + polyDeltaNorm.longitude * deltaNorm.longitude) > 0.9 ) { val distance = SphericalUtil.computeDistanceBetween(incidentCentroid, polyPoint) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt new file mode 100644 index 000000000..47036540f --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt @@ -0,0 +1,6 @@ +package com.crisiscleanup.core.model.data + +data class AppConfigData( + val claimCountThreshold: Int, + val closedClaimRatioThreshold: Float, +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt new file mode 100644 index 000000000..936ba63da --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt @@ -0,0 +1,5 @@ +package com.crisiscleanup.core.model.data + +data class AppMaintenanceData( + val ftsRebuildVersion: Long, +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt new file mode 100644 index 000000000..3fcbb6aaf --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt @@ -0,0 +1,7 @@ +package com.crisiscleanup.core.model.data + +data class IncidentClaimThreshold( + val incidentId: Long, + val claimedCount: Int, + val closedRatio: Float, +) 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 37e826435..2ae18b821 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 @@ -27,4 +27,6 @@ data class UserData( val isWorkScreenTableView: Boolean, val isSyncMediaImmediate: Boolean, + + val isMapSatelliteView: Boolean, ) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt index 41899a052..4dfb21cac 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt @@ -87,6 +87,24 @@ data class Worksite( fun toggleHighPriorityFlag() = toggleFlag(WorksiteFlagType.HighPriority) + val unclaimedCount by lazy { + workTypes.fold( + 0, + { acc, workType -> + acc + if (workType.orgClaim == null) 1 else 0 + }, + ) + } + + fun getClaimedCount(orgId: Long): Int { + return workTypes.fold( + 0, + { acc, workType -> + acc + if (workType.orgClaim == orgId) 1 else 0 + }, + ) + } + val isReleaseEligible = createdAt?.let { Clock.System.now().minus(it) > WorkTypeReleaseDaysThreshold } ?: false 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 c6a8b5baa..d7ca56710 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 @@ -2,6 +2,7 @@ package com.crisiscleanup.core.network import com.crisiscleanup.core.network.model.NetworkAccountProfileResult import com.crisiscleanup.core.network.model.NetworkCaseHistoryEvent +import com.crisiscleanup.core.network.model.NetworkClaimThreshold import com.crisiscleanup.core.network.model.NetworkCountResult import com.crisiscleanup.core.network.model.NetworkFlagsFormData import com.crisiscleanup.core.network.model.NetworkFlagsFormDataResult @@ -226,4 +227,6 @@ interface CrisisCleanupNetworkDataSource { ): NetworkTeamResult suspend fun getWorksiteChanges(after: Instant): List + + suspend fun getClaimThresholds(): NetworkClaimThreshold } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt index 914451b38..314c06aaa 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt @@ -19,4 +19,20 @@ data class NetworkAccountProfileResult( val organization: NetworkOrganizationShort?, @SerialName("active_roles") val activeRoles: Set?, + @SerialName("internal_state") + val internalState: NetworkProfileInternalState?, +) + +@Serializable +data class NetworkProfileInternalState( + @SerialName("incidents") + val incidentThresholdLookup: Map, +) + +@Serializable +data class NetworkIncidentClaimThreshold( + @SerialName("claimed_work_type_count") + val claimedCount: Int?, + @SerialName("claimed_work_type_closed_ratio") + val closedRatio: Float?, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt index de99a9b2b..ef698c012 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt @@ -47,6 +47,8 @@ data class NetworkIncident( val turnOnRelease: Boolean, @SerialName("is_archived") val isArchived: Boolean?, + @SerialName("ignore_claiming_thresholds") + val ignoreClaimingThresholds: Boolean? = null, @SerialName("form_fields") val fields: List? = null, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt new file mode 100644 index 000000000..fe4c09dd3 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt @@ -0,0 +1,16 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkPortalConfig( + val attr: NetworkClaimThreshold, +) + +@Serializable data class NetworkClaimThreshold( + @SerialName("claimed_work_type_count_threshold") + val workTypeCount: Int, + @SerialName("claimed_work_type_closed_ratio_threshold") + val workTypeClosedRatio: Float, +) 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 0eb4b4ebe..00ee3e31e 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 @@ -16,6 +16,7 @@ import com.crisiscleanup.core.network.model.NetworkListsResult import com.crisiscleanup.core.network.model.NetworkLocationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsSearchResult +import com.crisiscleanup.core.network.model.NetworkPortalConfig import com.crisiscleanup.core.network.model.NetworkRedeployRequestsResult import com.crisiscleanup.core.network.model.NetworkTeamResult import com.crisiscleanup.core.network.model.NetworkUserProfile @@ -384,6 +385,9 @@ private interface DataSourceApi { @Query("since") after: Instant, ): NetworkWorksiteChangesResult + + @GET("portals/current") + suspend fun getCurrentPortalConfig(): NetworkPortalConfig } private val worksiteCoreDataFields = listOf( @@ -721,4 +725,6 @@ class DataApiClient @Inject constructor( } return result.changes ?: emptyList() } + + override suspend fun getClaimThresholds() = networkApi.getCurrentPortalConfig().attr } diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt index 81d6646e5..a1b635917 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt @@ -33,6 +33,7 @@ internal fun fillNetworkIncident( activePhone: List? = null, turnOnRelease: Boolean = false, isArchived: Boolean = false, + ignoreClaimingThresholds: Boolean? = null, ) = NetworkIncident( id, Instant.parse(startAt), @@ -43,7 +44,8 @@ internal fun fillNetworkIncident( incidentType, activePhone, turnOnRelease, - isArchived, + isArchived = isArchived, + ignoreClaimingThresholds = ignoreClaimingThresholds, ) internal fun fillNetworkLocation( diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt index a66053231..ef666b350 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt @@ -118,6 +118,7 @@ class IterableStringSerializerTest { phoneNumbers, false, null, + null, emptyList(), ) } 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 1427e2b04..40a9c08b8 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 @@ -20,4 +20,5 @@ val UserDataNone = UserData( teamMapBounds = IncidentCoordinateBoundsNone, isWorkScreenTableView = false, isSyncMediaImmediate = false, + isMapSatelliteView = false, ) 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 6ec55425a..f71e32dad 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 @@ -7,6 +7,7 @@ import com.crisiscleanup.core.common.log.TagLogger import com.crisiscleanup.core.commoncase.model.FormFieldNode import com.crisiscleanup.core.commoncase.model.WORK_FORM_GROUP_KEY import com.crisiscleanup.core.commoncase.model.flatten +import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -43,6 +44,7 @@ internal class CaseEditorDataLoader( incidentIdIn: Long, worksiteIdIn: Long?, accountDataRepository: AccountDataRepository, + accountDataRefresher: AccountDataRefresher, incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, @@ -375,6 +377,14 @@ internal class CaseEditorDataLoader( } } + coroutineScope.launch(coroutineDispatcher) { + try { + accountDataRefresher.updateIncidentClaimThreshold() + } catch (e: Exception) { + logger.logException(e) + } + } + coroutineScope.launch(coroutineDispatcher) { try { languageRefresher.pullLanguages() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 6b1a0bd6a..9d2ca237c 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -26,7 +26,10 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone +import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppPreferencesRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -66,8 +69,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn 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 @@ -82,11 +87,12 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class CreateEditCaseViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - accountDataRepository: AccountDataRepository, + private val accountDataRepository: AccountDataRepository, + accountDataRefresher: AccountDataRefresher, incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, - worksitesRepository: WorksitesRepository, + private val worksitesRepository: WorksitesRepository, languageRepository: LanguageTranslationsRepository, languageRefresher: LanguageRefresher, workTypeStatusRepository: WorkTypeStatusRepository, @@ -94,14 +100,15 @@ class CreateEditCaseViewModel @Inject constructor( private val incidentSelector: IncidentSelector, private val translator: KeyResourceTranslator, private val worksiteChangeRepository: WorksiteChangeRepository, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, localImageRepository: LocalImageRepository, private val worksiteImageRepository: WorksiteImageRepository, + private val preferencesRepository: AppPreferencesRepository, private val syncPusher: SyncPusher, networkMonitor: NetworkMonitor, packageManager: PackageManager, appEnv: AppEnv, @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, - @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, inputValidator: InputValidator, searchWorksitesRepository: SearchWorksitesRepository, @@ -112,8 +119,27 @@ class CreateEditCaseViewModel @Inject constructor( locationProvider: LocationProvider, addressSearchRepository: AddressSearchRepository, drawableResourceBitmapProvider: DrawableResourceBitmapProvider, + + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @Dispatcher(Default) coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : EditCaseBaseViewModel(editableWorksiteProvider, translator, logger), CaseCameraMediaManager { + companion object { + internal suspend fun isOverClaiming( + orgId: Long, + startingWorksite: Worksite, + updatedWorksite: Worksite, + incidentClaimThresholdRepository: IncidentClaimThresholdRepository, + ): Boolean { + val endClaimCount = updatedWorksite.getClaimedCount(orgId) + val startClaimCount = startingWorksite.getClaimedCount(orgId) + val deltaClaimCount = endClaimCount - startClaimCount + return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( + updatedWorksite.id, + deltaClaimCount, + ) + } + } + private val caseEditorArgs = CaseEditorArgs(savedStateHandle) private var worksiteIdArg = caseEditorArgs.worksiteId val isCreateWorksite: Boolean @@ -137,6 +163,9 @@ class CreateEditCaseViewModel @Inject constructor( val showInvalidWorksiteSave = MutableStateFlow(false) val invalidWorksiteInfo = mutableStateOf(InvalidWorksiteInfo()) + override val isMapSatelliteView = + preferencesRepository.userPreferences.map { it.isMapSatelliteView } + private val editingWorksite = editableWorksiteProvider.editableWorksite val photosWorksiteId: Long get() = editingWorksite.value.id @@ -212,6 +241,8 @@ class CreateEditCaseViewModel @Inject constructor( override val capturePhotoUri: Uri? get() = worksiteImageRepository.newPhotoUri + var isOverClaimingWork by mutableStateOf(false) + init { updateHeaderTitle() @@ -231,6 +262,7 @@ class CreateEditCaseViewModel @Inject constructor( incidentIdIn, worksiteIdArg, accountDataRepository, + accountDataRefresher, incidentsRepository, incidentRefresher, incidentBoundsProvider, @@ -453,6 +485,12 @@ class CreateEditCaseViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + override fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + fun scheduleSync() { val worksite = editingWorksite.value if (!(worksite.isNew || isSyncing.value)) { @@ -586,6 +624,20 @@ class CreateEditCaseViewModel @Inject constructor( } } + private suspend fun isOverClaiming( + startingWorksite: Worksite, + updatedWorksite: Worksite, + ): Boolean { + val accountData = accountDataRepository.accountData.first() + val orgId = accountData.org.id + return isOverClaiming( + orgId, + startingWorksite, + updatedWorksite, + incidentClaimThresholdRepository, + ) + } + fun saveChanges( claimUnclaimed: Boolean, backOnSuccess: Boolean = true, @@ -608,7 +660,10 @@ class CreateEditCaseViewModel @Inject constructor( val saveIncidentId = saveChangeIncident.id val isIncidentChange = saveIncidentId != EmptyIncident.id && saveIncidentId != worksite.incidentId - if (worksite == initialWorksite && !isIncidentChange) { + if (worksite == initialWorksite && + !isIncidentChange && + (!claimUnclaimed || worksite.unclaimedCount == 0) + ) { if (hasNewWorksitePhotosImages) { propertyEditor?.propertyInputData?.let { setInvalidSection(0, it) @@ -661,6 +716,13 @@ class CreateEditCaseViewModel @Inject constructor( what3Words = updatedWhat3Words, ) + if (!isCreateWorksite && + isOverClaiming(worksite, updatedWorksite) + ) { + isOverClaimingWork = true + return@launch + } + worksiteIdArg = worksiteChangeRepository.saveWorksiteChange( initialWorksite, updatedWorksite, @@ -683,6 +745,16 @@ class CreateEditCaseViewModel @Inject constructor( syncPusher.appPushWorksite(worksiteId, true) + if (isCreateWorksite) { + incidentClaimThresholdRepository.onWorksiteCreated(worksiteId) + } + + worksitesRepository.setRecentWorksite( + incidentId = updatedIncidentId, + worksiteId = worksiteId, + viewStart = Clock.System.now(), + ) + if (isIncidentChange) { changeExistingWorksite.value = ExistingWorksiteIdentifier(saveIncidentId, worksiteId) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt index 3290f69b6..8e35ac3f2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt @@ -5,6 +5,7 @@ import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger +import kotlinx.coroutines.flow.Flow abstract class EditCaseBaseViewModel( protected val worksiteProvider: EditableWorksiteProvider, @@ -14,6 +15,10 @@ abstract class EditCaseBaseViewModel( val breakGlassHint = translator("actions.edit") val helpHint = translator("actions.help_alt") + abstract val isMapSatelliteView: Flow + + abstract fun setMapSatelliteView(isSatellite: Boolean) + abstract fun onSystemBack(): Boolean abstract fun onNavigateBack(): Boolean diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt index 5ba35f079..ce138eb0a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt @@ -19,6 +19,7 @@ import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.commoncase.model.CaseSummaryResult import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.SearchWorksitesRepository import com.crisiscleanup.core.mapmarker.DrawableResourceBitmapProvider import com.crisiscleanup.core.mapmarker.IncidentBoundsProvider @@ -617,6 +618,7 @@ internal class EditableLocationDataEditor( @HiltViewModel class EditCaseLocationViewModel @Inject constructor( worksiteProvider: EditableWorksiteProvider, + private val preferencesRepository: AppPreferencesRepository, permissionManager: PermissionManager, locationProvider: LocationProvider, boundsProvider: IncidentBoundsProvider, @@ -629,8 +631,12 @@ class EditCaseLocationViewModel @Inject constructor( translator: KeyResourceTranslator, @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, @Dispatcher(Default) coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default, - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : EditCaseBaseViewModel(worksiteProvider, translator, logger) { + + override val isMapSatelliteView = + preferencesRepository.userPreferences.map { it.isMapSatelliteView } + val editor: CaseLocationDataEditor = EditableLocationDataEditor( worksiteProvider, permissionManager, @@ -655,6 +661,12 @@ class EditCaseLocationViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + override fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + private fun onBackValidateSaveWorksite() = editor.onBackValidateSaveWorksite() override fun onSystemBack() = onBackValidateSaveWorksite() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt index ffffa9a79..4c15a8c08 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt @@ -1,12 +1,6 @@ package com.crisiscleanup.feature.caseeditor -import com.crisiscleanup.core.common.KeyResourceTranslator -import com.crisiscleanup.core.common.log.AppLogger -import com.crisiscleanup.core.common.log.CrisisCleanupLoggers -import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.feature.caseeditor.model.NotesFlagsInputData -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject interface CaseNotesFlagsDataEditor { val notesFlagsInputData: NotesFlagsInputData @@ -34,18 +28,3 @@ internal class EditableNotesFlagsDataEditor( return true } } - -@HiltViewModel -class EditCaseNotesFlagsViewModel @Inject constructor( - worksiteProvider: EditableWorksiteProvider, - translator: KeyResourceTranslator, - @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, -) : EditCaseBaseViewModel(worksiteProvider, translator, logger) { - val editor: CaseNotesFlagsDataEditor = EditableNotesFlagsDataEditor(worksiteProvider) - - private fun validateSaveWorksite() = editor.validateSaveWorksite() - - override fun onSystemBack() = validateSaveWorksite() - - override fun onNavigateBack() = validateSaveWorksite() -} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index a62da68f3..7ed6f752b 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -30,6 +30,8 @@ import com.crisiscleanup.core.commoncase.WorkTypeTransferType import com.crisiscleanup.core.commoncase.oneDecimalFormat import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppPreferencesRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -92,10 +94,12 @@ class ViewCaseViewModel @Inject constructor( languageRefresher: LanguageRefresher, workTypeStatusRepository: WorkTypeStatusRepository, localImageRepository: LocalImageRepository, + private val preferencesRepository: AppPreferencesRepository, private val editableWorksiteProvider: EditableWorksiteProvider, val transferWorkTypeProvider: TransferWorkTypeProvider, permissionManager: PermissionManager, private val translator: KeyResourceTranslator, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val worksiteImageRepository: WorksiteImageRepository, private val syncPusher: SyncPusher, @@ -122,6 +126,8 @@ class ViewCaseViewModel @Inject constructor( private var inBoundsPinIcon: BitmapDescriptor? = null private var outOfBoundsPinIcon: BitmapDescriptor? = null + val isMapSatelliteView = preferencesRepository.userPreferences.map { it.isMapSatelliteView } + val isSyncing = combine( worksiteChangeRepository.syncingWorksiteIds, localImageRepository.syncingWorksiteId, @@ -221,6 +227,8 @@ class ViewCaseViewModel @Inject constructor( val actionDescriptionMessage = MutableStateFlow("") + var isOverClaimingWork by mutableStateOf(false) + init { updateHeaderTitle() @@ -231,6 +239,7 @@ class ViewCaseViewModel @Inject constructor( incidentIdArg, worksiteIdArg, accountDataRepository, + accountDataRefresher, incidentsRepository, incidentRefresher, incidentBoundsProvider, @@ -569,6 +578,18 @@ class ViewCaseViewModel @Inject constructor( } } + private suspend fun isOverClaiming( + startingWorksite: Worksite, + changedWorksite: Worksite, + ) = organizationId?.let { orgId -> + return@let CreateEditCaseViewModel.isOverClaiming( + orgId, + startingWorksite, + changedWorksite, + incidentClaimThresholdRepository, + ) + } ?: false + private val viewStateCaseData: CaseEditorViewState.CaseData? get() = viewState.value.asCaseData() private val organizationId: Long? @@ -589,6 +610,11 @@ class ViewCaseViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { isSavingWorksite.value = true try { + if (isOverClaiming(startingWorksite, changedWorksite)) { + isOverClaimingWork = true + return@launch + } + worksiteChangeRepository.saveWorksiteChange( startingWorksite, changedWorksite, @@ -644,6 +670,12 @@ class ViewCaseViewModel @Inject constructor( } } + fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + fun jumpToCaseOnMap() { caseData.value?.let { val coordinates = it.worksite.coordinates diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt index 22ea1eb57..a4340a43f 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt @@ -56,14 +56,11 @@ class WrongLocationFlagManager( started = SharingStarted.WhileSubscribed(), ) - val validCoordinates = wrongLocationCoordinatesParse.mapLatest { - it?.let { latLng -> + val validCoordinates = wrongLocationCoordinatesParse.mapLatest { parsed -> + parsed?.let { isVerifyingCoordinates.value = true try { - val results = addressSearchRepository.getAddress(latLng) - results?.let { address -> - return@mapLatest address - } + return@mapLatest addressSearchRepository.getAddress(parsed) } finally { isVerifyingCoordinates.value = false } 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 13dfadae2..896b0fea0 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 @@ -111,6 +111,11 @@ internal fun CreateEditCaseRoute( openPhoto = openPhoto, ) } + + if (viewModel.isOverClaimingWork) { + val closeDialog = remember(viewModel) { { viewModel.isOverClaimingWork = false } } + OverClaimAlertDialog(closeDialog) + } } } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt index 01d271864..333a8d19d 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -49,9 +50,10 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState @Composable private fun AddressSummaryInColumn( @@ -115,6 +117,7 @@ private fun AddressSummaryInColumn( internal fun BoxScope.LocationMapView( viewModel: EditCaseBaseViewModel, editor: CaseLocationDataEditor, + isSatelliteView: Boolean, modifier: Modifier = Modifier, zoomControls: Boolean = false, disablePanning: Boolean = false, @@ -138,13 +141,16 @@ internal fun BoxScope.LocationMapView( disablePanning = disablePanning, ) - val markerState = rememberMarkerState() val coordinates by editor.locationInputData.coordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val mapMarkerIcon by editor.mapMarkerIcon.collectAsStateWithLifecycle() - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } GoogleMap( modifier = modifier.testTag("mapView"), uiSettings = uiSettings, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt index bd0793a36..27ee0fd2f 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt @@ -3,6 +3,7 @@ package com.crisiscleanup.feature.caseeditor.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -29,6 +30,7 @@ import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.ExplainLocationPermissionDialog import com.crisiscleanup.core.designsystem.component.LIST_DETAIL_DETAIL_WEIGHT import com.crisiscleanup.core.designsystem.component.LIST_DETAIL_LIST_WEIGHT +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction import com.crisiscleanup.core.designsystem.component.cancelButtonColors import com.crisiscleanup.core.designsystem.component.listDetailDetailMaxWidth @@ -47,9 +49,10 @@ import com.google.android.gms.maps.Projection import com.google.android.gms.maps.model.CameraPosition import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState @Composable internal fun EditCaseMapMoveLocationRoute( @@ -72,6 +75,8 @@ internal fun EditCaseMapMoveLocationRoute( val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) + val isListDetailLayout = LocalDimensions.current.isListDetailWidth val t = LocalAppTranslator.current @@ -86,20 +91,24 @@ internal fun EditCaseMapMoveLocationRoute( title, locationQuery, editor, - isOnline, + isOnline = isOnline, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, onBack, - isEditable, onUseMyLocation, + viewModel::setMapSatelliteView, ) } else { PortraitLayout( title, locationQuery, editor, - isOnline, + isOnline = isOnline, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, onBack, - isEditable, onUseMyLocation, + viewModel::setMapSatelliteView, ) } @@ -139,6 +148,27 @@ private fun UseMyLocationAction( } } +@Composable +private fun ColumnScope.MoveMapContainer( + editor: CaseLocationDataEditor, + isEditable: Boolean, + isMapSatelliteView: Boolean, + setMapSatelliteView: (Boolean) -> Unit, +) { + Box(Modifier.weight(1f)) { + MoveMapUnderLocation( + editor, + isEditable = isEditable, + isSatelliteView = isMapSatelliteView, + ) + + MapViewToggleButton( + isMapSatelliteView, + setMapSatelliteView, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PortraitLayout( @@ -146,9 +176,11 @@ private fun PortraitLayout( locationQuery: String, editor: CaseLocationDataEditor, isOnline: Boolean, + isEditable: Boolean, + isMapSatelliteView: Boolean, onBack: () -> Unit = {}, - isEditable: Boolean = false, onUseMyLocation: () -> Unit = {}, + setMapSatelliteView: (Boolean) -> Unit = {}, ) { Column { TopAppBarBackAction( @@ -165,9 +197,12 @@ private fun PortraitLayout( } if (locationQuery.isBlank()) { - Box(Modifier.weight(1f)) { - MoveMapUnderLocation(editor, isEditable) - } + MoveMapContainer( + editor, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, + setMapSatelliteView, + ) UseMyLocationAction( isEditable = isEditable, @@ -189,9 +224,11 @@ private fun ListDetailLayout( locationQuery: String, editor: CaseLocationDataEditor, isOnline: Boolean, + isEditable: Boolean, + isMapSatelliteView: Boolean, onBack: () -> Unit = {}, - isEditable: Boolean = false, onUseMyLocation: () -> Unit = {}, + setMapSatelliteView: (Boolean) -> Unit = {}, ) { Row { Column(Modifier.weight(LIST_DETAIL_LIST_WEIGHT)) { @@ -224,9 +261,12 @@ private fun ListDetailLayout( .sizeIn(maxWidth = listDetailDetailMaxWidth), ) { if (locationQuery.isBlank()) { - Box(Modifier.weight(1f)) { - MoveMapUnderLocation(editor, isEditable) - } + MoveMapContainer( + editor, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, + setMapSatelliteView, + ) } else { editor.isMapLoaded = false AddressSearchResults(editor, locationQuery, isEditable = isEditable) @@ -239,6 +279,7 @@ private fun ListDetailLayout( private fun BoxScope.MoveMapUnderLocation( editor: CaseLocationDataEditor, isEditable: Boolean, + isSatelliteView: Boolean, modifier: Modifier = Modifier, ) { val onMapCameraChange = remember(editor) { @@ -253,9 +294,8 @@ private fun BoxScope.MoveMapUnderLocation( val mapCameraZoom by editor.mapCameraZoom.collectAsStateWithLifecycle() - val markerState = rememberMarkerState() val coordinates by editor.locationInputData.coordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val mapMarkerIcon by editor.mapMarkerIcon.collectAsStateWithLifecycle() @@ -266,7 +306,11 @@ private fun BoxScope.MoveMapUnderLocation( zoomGesturesEnabled = isEditable, ) } - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } val cameraPositionState = rememberCameraPositionState() GoogleMap( modifier = modifier, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt new file mode 100644 index 000000000..830093656 --- /dev/null +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.feature.caseeditor.ui + +import androidx.compose.runtime.Composable +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog +import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton +import com.crisiscleanup.core.designsystem.component.LinkifyHtmlText + +@Composable +fun OverClaimAlertDialog( + closeDialog: () -> Unit, +) { + val t = LocalAppTranslator.current + CrisisCleanupAlertDialog( + onDismissRequest = closeDialog, + title = t("info.claiming_restricted_threshold_exceeded_title"), + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.ok"), + onClick = closeDialog, + ) + }, + ) { + LinkifyHtmlText(t("info.claiming_restricted_threshold_exceeded")) + } +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt index d4cb9cede..19c4505a5 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt @@ -1,10 +1,11 @@ package com.crisiscleanup.feature.caseeditor.ui -import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.LocalTextStyle @@ -13,6 +14,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.coerceAtMost @@ -22,6 +24,7 @@ import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.ExplainLocationPermissionDialog import com.crisiscleanup.core.designsystem.component.HelpRow +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.OutlinedSingleLineTextField import com.crisiscleanup.core.designsystem.component.WithHelpDialog import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme @@ -92,7 +95,8 @@ internal fun PropertyLocationView( ) } - val screenHeight = Configuration.SCREEN_HEIGHT_DP_UNDEFINED.dp + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) + val screenHeight = LocalWindowInfo.current.containerSize.height.dp val mapHeight = screenHeight.times(0.5f).coerceAtMost(240.dp) val mapModifier = Modifier.sizeIn(maxHeight = mapHeight) val cameraPositionState = rememberCameraPositionState() @@ -100,10 +104,17 @@ internal fun PropertyLocationView( LocationMapView( viewModel, editor, + isMapSatelliteView, + Modifier.fillMaxSize(), zoomControls = true, disablePanning = true, cameraPositionState = cameraPositionState, ) + + MapViewToggleButton( + isMapSatelliteView, + viewModel::setMapSatelliteView, + ) } LocationMapActionBar( diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt index 886431ddb..a0aff002c 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt @@ -77,6 +77,7 @@ import com.crisiscleanup.core.designsystem.component.LeadingIconChip import com.crisiscleanup.core.designsystem.component.LinkifyEmailText import com.crisiscleanup.core.designsystem.component.LinkifyLocationText import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.TemporaryDialog import com.crisiscleanup.core.designsystem.component.WorkTypeBusyAction import com.crisiscleanup.core.designsystem.component.WorkTypePrimaryAction @@ -113,9 +114,10 @@ import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch // TODO Use/move common dimensions @@ -244,6 +246,11 @@ internal fun EditExistingCaseRoute( val actionDescription by viewModel.actionDescriptionMessage.collectAsStateWithLifecycle() TemporaryDialog(actionDescription) + + if (viewModel.isOverClaimingWork) { + val closeDialog = remember(viewModel) { { viewModel.isOverClaimingWork = false } } + OverClaimAlertDialog(closeDialog) + } } } @@ -570,10 +577,11 @@ internal fun PropertyInfoRow( @Composable private fun CaseInfoView( worksite: Worksite, - viewModel: ViewCaseViewModel = hiltViewModel(), copyToClipboard: (String?) -> Unit = {}, + viewModel: ViewCaseViewModel = hiltViewModel(), ) { val mapMarkerIcon by viewModel.mapMarkerIcon.collectAsStateWithLifecycle() + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) val workTypeProfile by viewModel.workTypeProfile.collectAsStateWithLifecycle() val removeFlag = remember(viewModel) { { flag: WorksiteFlag -> viewModel.removeFlag(flag) } } @@ -617,9 +625,11 @@ private fun CaseInfoView( flagItems(worksite, removeFlag) propertyInfoItems( worksite, + isMapSatelliteView, mapMarkerIcon, copyToClipboard, distanceAwayText, + viewModel::setMapSatelliteView, viewModel::jumpToCaseOnMap, ) workItems( @@ -712,9 +722,11 @@ private fun FlagChip( @OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.propertyInfoItems( worksite: Worksite, + isMapSatelliteView: Boolean, mapMarkerIcon: BitmapDescriptor? = null, copyToClipboard: (String?) -> Unit = {}, distanceAwayText: String = "", + setMapSatelliteView: (Boolean) -> Unit = {}, onJumpToCaseOnMap: () -> Unit = {}, ) { itemInfoSectionHeader(0, "caseForm.property_information") @@ -815,15 +827,26 @@ private fun LazyListScope.propertyInfoItems( } } - PropertyInfoMapView( - worksite.coordinates, - // TODO Common dimensions + Box( Modifier - .testTag("editCasePropertyInfoMapView") + // TODO Common dimensions .height(192.dp) .padding(top = edgeSpacingHalf), - mapMarkerIcon = mapMarkerIcon, - ) + ) { + PropertyInfoMapView( + worksite.coordinates, + isMapSatelliteView, + Modifier + .fillMaxSize() + .testTag("editCasePropertyInfoMapView"), + mapMarkerIcon = mapMarkerIcon, + ) + + MapViewToggleButton( + isMapSatelliteView, + setMapSatelliteView, + ) + } } } } @@ -1088,6 +1111,7 @@ data class IconTextAction( @Composable private fun PropertyInfoMapView( coordinates: LatLng, + isSatelliteView: Boolean, modifier: Modifier = Modifier, mapMarkerIcon: BitmapDescriptor? = null, onMapLoaded: () -> Unit = {}, @@ -1098,13 +1122,16 @@ private fun PropertyInfoMapView( disablePanning = true, ) - val markerState = rememberMarkerState() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val update = CameraUpdateFactory.newLatLngZoom(coordinates, 13f) cameraPositionState.move(update) - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } GoogleMap( modifier = modifier, uiSettings = uiSettings, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt index 93e986b7c..b92ff5ace 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt @@ -92,7 +92,11 @@ internal fun ColumnScope.WrongLocationFlagView( } } - val onSave = remember(viewModel) { { viewModel.updateLocation(validCoordinates) } } + val onSave = remember(viewModel, validCoordinates) { + { + viewModel.updateLocation(validCoordinates) + } + } AddFlagSaveActionBar( onSave = onSave, onCancel = onBack, 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 5dccc5916..6249ae200 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 @@ -155,6 +155,8 @@ class CasesViewModel @Inject constructor( val filtersCount = filterRepository.filtersCount + val isMapSatelliteView = appPreferencesRepository.userPreferences.map { it.isMapSatelliteView } + val isTableView = qsm.isTableView private val tableDataDistanceSortSearchRadius = 100.0f @@ -214,7 +216,14 @@ class CasesViewModel @Inject constructor( } val editedWorksiteLocation: LatLng? - get() = worksiteLocationEditor.takeEditedLocation()?.let { LatLng(it.first, it.second) } + get() = worksiteLocationEditor.takeEditedLocation()?.let { + // TODO Separate side effect + if (isTableView.value) { + setContentViewType(false) + } + + LatLng(it.first, it.second) + } val isIncidentLoading = incidentsRepository.isLoading @@ -641,6 +650,12 @@ class CasesViewModel @Inject constructor( fun zoomToInteractive() = adjustMapZoom(MAP_MARKERS_ZOOM_LEVEL + 0.5f) + fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setMapSatelliteView(isSatellite) + } + } + private fun setMapToMyCoordinates() { viewModelScope.launch { locationProvider.getLocation(10.seconds)?.let { myLocation -> 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 2bedb74ad..858707e59 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 @@ -400,7 +400,7 @@ internal fun CasesScreen( enableIncidentSelect = enableIncidentSelect, ) } else { - var isSatelliteMapType by remember { mutableStateOf(false) } + val isSatelliteMapType by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) CasesMapView( mapCameraBounds, @@ -426,9 +426,7 @@ internal fun CasesScreen( onCasesAction(CasesAction.Layers) }, isSatelliteMapType = isSatelliteMapType, - onToggleSatelliteType = { isSatellite: Boolean -> - isSatelliteMapType = isSatellite - }, + onToggleSatelliteType = viewModel::setMapSatelliteView, ) } CasesOverlayElements( @@ -505,13 +503,8 @@ internal fun BoxScope.CasesMapView( isMyLocation = isMyLocationEnabled, ) LaunchedEffect(isSatelliteMapType) { - mapProperties = mapProperties.copy( - mapType = if (isSatelliteMapType) { - MapType.SATELLITE - } else { - MapType.NORMAL - }, - ) + val mapType = if (isSatelliteMapType) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) } GoogleMap( modifier = Modifier 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 e50852074..b5e25d702 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 @@ -68,7 +68,7 @@ import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch @Composable @@ -463,9 +463,8 @@ private fun MovableMapView( val mapCameraZoom by editor.mapCameraZoom.collectAsStateWithLifecycle() - val markerState = rememberMarkerState() val coordinates by editor.centerCoordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) var uiSettings by rememberMapUiSettings() LaunchedEffect(isEditable, isMovable) { diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt index 144bf6922..78bd0d703 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt @@ -72,7 +72,7 @@ class RequestRedeployViewModel @Inject constructor( init { viewModelScope.launch(ioDispatcher) { - accountDataRefresher.updateApprovedIncidents(true) + accountDataRefresher.updateProfileIncidentsData(true) requestedIncidentsStream.value = requestRedeployRepository.getRequestedIncidents() 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 233f1410f..731b17971 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -14,6 +14,7 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.core.data.incidentcache.IncidentDataPullReporter import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppConfigRepository import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentCacheRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -42,6 +43,7 @@ class AppSyncer @Inject constructor( incidentDataPullReporter: IncidentDataPullReporter, private val languageRepository: LanguageTranslationsRepository, private val statusRepository: WorkTypeStatusRepository, + private val appConfigRepository: AppConfigRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val appPreferencesRepository: AppPreferencesRepository, private val networkMonitor: NetworkMonitor, @@ -215,6 +217,25 @@ class AppSyncer @Inject constructor( } } + override fun appPullAppConfig() { + applicationScope.launch(ioDispatcher) { + syncPullAppConfig() + } + } + + override suspend fun syncPullAppConfig(): SyncResult { + onlinePrecondition()?.let { + return it + } + + return try { + appConfigRepository.pullAppConfig() + SyncResult.Success("App config pulled") + } catch (e: Exception) { + SyncResult.Error(e.message ?: "App config pull fail") + } + } + override fun appPushWorksite(worksiteId: Long, scheduleMediaSync: Boolean) { applicationScope.launch(ioDispatcher) { pushPrecondition()?.let { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt index f238b5140..3f98c1441 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt @@ -5,6 +5,7 @@ import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkerParameters +import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -21,8 +22,7 @@ import kotlinx.datetime.Clock import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.days -private val clearDuration = 180.days -private const val REPEAT_DURATION_DAYS = 15L +private const val REPEAT_DURATION_DAYS = 2L @HiltWorker internal class InactivityWorker @AssistedInject constructor( @@ -30,6 +30,7 @@ internal class InactivityWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters, appMetricsRepository: LocalAppMetricsRepository, private val appDataManagementRepository: AppDataManagementRepository, + private val appEnv: AppEnv, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(appContext, workerParams) { @@ -38,6 +39,13 @@ internal class InactivityWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(ioDispatcher) { val latestAppOpen = appMetrics.first().appOpen.date val delta = Clock.System.now().minus(latestAppOpen) + val clearDuration = if (appEnv.isProduction) { + 60.days + } else if (appEnv.isDebuggable) { + 3.days + } else { + 6.days + } when { delta in clearDuration..999.days -> { if (appDataManagementRepository.backgroundClearAppData(false)) { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt index 7c0124c79..2e4193a85 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt @@ -58,6 +58,9 @@ internal class SyncWorker @AssistedInject constructor( async { syncPuller.syncPullStatuses() is SyncResult.Success }, + async { + syncPuller.syncPullAppConfig() is SyncResult.Success + }, ).all { it } syncLogger