diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d6eaada6..4807eaad2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 190 + val buildVersion = 192 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 9e150c644..837e4aeab 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -48,6 +48,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.AuthState import com.crisiscleanup.MainActivityViewModel +import com.crisiscleanup.MainActivityViewState import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.designsystem.LayoutProvider import com.crisiscleanup.core.designsystem.LocalAppTranslator @@ -202,9 +203,16 @@ private fun BoxScope.LoadedContent( } BusyIndicatorFloatingTopCenter(isFetching) } else { + val mainViewState by viewModel.viewState.collectAsStateWithLifecycle() + val hideOnboarding = + (mainViewState as? MainActivityViewState.Success)?.userData?.shouldHideOnboarding + ?: true + val isOnboarding = !hideOnboarding + NavigableContent( snackbarHostState, appState, + isOnboarding, ) { openAuthentication = true } if ( @@ -306,6 +314,7 @@ private fun AcceptTermsContent( private fun NavigableContent( snackbarHostState: SnackbarHostState, appState: CrisisCleanupAppState, + isOnboarding: Boolean, openAuthentication: () -> Unit, ) { val showNavigation = appState.isTopLevelRoute @@ -385,7 +394,7 @@ private fun NavigableContent( onBack = appState::onBack, openAuthentication = openAuthentication, modifier = Modifier.weight(1f), - startDestination = appState.lastTopLevelRoute(), + startDestination = appState.lastTopLevelRoute(isOnboarding), ) } diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupAppState.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupAppState.kt index d6a49cdf7..e05c79656 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupAppState.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupAppState.kt @@ -109,10 +109,14 @@ class CrisisCleanupAppState( MENU, ) - private var priorTopLevelDestination: TopLevelDestination = MENU + private var priorTopLevelDestination: TopLevelDestination? = null @Composable - fun lastTopLevelRoute(): String { + fun lastTopLevelRoute(isOnboarding: Boolean): String { + if (isOnboarding && priorTopLevelDestination == null) { + return MENU_ROUTE + } + return when (priorTopLevelDestination) { DASHBOARD -> DASHBOARD_ROUTE TEAM -> TEAM_ROUTE diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesDataCache.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesDataCache.kt index d88379584..491397864 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesDataCache.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesDataCache.kt @@ -4,7 +4,9 @@ import android.content.Context 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.model.IncidentCacheDataPageRequest import com.crisiscleanup.core.data.model.IncidentWorksitesPageRequest +import com.crisiscleanup.core.data.model.IncidentWorksitesSecondaryDataPageRequest import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.coroutineScope @@ -37,6 +39,25 @@ interface WorksitesNetworkDataCache { incidentId: Long, pageIndex: Int, ) + + fun loadWorksitesSecondaryData( + incidentId: Long, + pageIndex: Int, + expectedCount: Int, + ): IncidentWorksitesSecondaryDataPageRequest? + + suspend fun saveWorksitesSecondaryData( + incidentId: Long, + pageCount: Int, + pageIndex: Int, + expectedCount: Int, + updatedAfter: Instant?, + ) + + suspend fun deleteWorksitesSecondaryData( + incidentId: Long, + pageIndex: Int, + ) } class WorksitesNetworkDataFileCache @Inject constructor( @@ -48,16 +69,16 @@ class WorksitesNetworkDataFileCache @Inject constructor( "incident-$incidentId-worksites-short-$page.json" @OptIn(ExperimentalSerializationApi::class) - override fun loadWorksitesShort( + private inline fun loadCacheData( + cacheFileName: String, incidentId: Long, pageIndex: Int, expectedCount: Int, - ): IncidentWorksitesPageRequest? { - val cacheFileName = shortWorksitesFileName(incidentId, pageIndex) + ): T? { val cacheFile = File(context.cacheDir, cacheFileName) if (cacheFile.exists()) { cacheFile.inputStream().use { - val cachedData: IncidentWorksitesPageRequest = Json.decodeFromStream(it) + val cachedData: T = Json.decodeFromStream(it) if (cachedData.incidentId == incidentId && cachedData.page == pageIndex && cachedData.totalCount == expectedCount && @@ -71,6 +92,17 @@ class WorksitesNetworkDataFileCache @Inject constructor( return null } + override fun loadWorksitesShort( + incidentId: Long, + pageIndex: Int, + expectedCount: Int, + ) = loadCacheData( + shortWorksitesFileName(incidentId, pageIndex), + incidentId, + pageIndex, + expectedCount, + ) + @OptIn(ExperimentalSerializationApi::class) override suspend fun saveWorksitesShort( incidentId: Long, @@ -79,8 +111,6 @@ class WorksitesNetworkDataFileCache @Inject constructor( expectedCount: Int, updatedAfter: Instant?, ) = coroutineScope { - val cacheFileName = shortWorksitesFileName(incidentId, pageIndex) - try { loadWorksitesShort(incidentId, pageIndex, expectedCount)?.let { return@coroutineScope @@ -106,6 +136,7 @@ class WorksitesNetworkDataFileCache @Inject constructor( worksites, ) + val cacheFileName = shortWorksitesFileName(incidentId, pageIndex) val cacheFile = File(context.cacheDir, cacheFileName) cacheFile.outputStream().use { Json.encodeToStream(dataCache, it) @@ -115,8 +146,12 @@ class WorksitesNetworkDataFileCache @Inject constructor( override suspend fun deleteWorksitesShort( incidentId: Long, pageIndex: Int, - ) = coroutineScope { + ) { val cacheFileName = shortWorksitesFileName(incidentId, pageIndex) + deleteCacheFile(cacheFileName) + } + + private suspend fun deleteCacheFile(cacheFileName: String) = coroutineScope { try { val cacheFile = File(context.cacheDir, cacheFileName) if (cacheFile.exists()) { @@ -126,4 +161,66 @@ class WorksitesNetworkDataFileCache @Inject constructor( logger.logDebug("Error deleting cache file $cacheFileName. ${e.message}") } } + + private fun secondaryDataFileName(incidentId: Long, page: Int) = + "incident-$incidentId-worksites-secondary-data-$page.json" + + override fun loadWorksitesSecondaryData( + incidentId: Long, + pageIndex: Int, + expectedCount: Int, + ) = loadCacheData( + secondaryDataFileName(incidentId, pageIndex), + incidentId, + pageIndex, + expectedCount, + ) + + @OptIn(ExperimentalSerializationApi::class) + override suspend fun saveWorksitesSecondaryData( + incidentId: Long, + pageCount: Int, + pageIndex: Int, + expectedCount: Int, + updatedAfter: Instant?, + ) = coroutineScope { + try { + loadWorksitesSecondaryData(incidentId, pageIndex, expectedCount)?.let { + return@coroutineScope + } + } catch (e: Exception) { + logger.logDebug("Error reading cache file ${e.message}") + } + + val requestTime = Clock.System.now() + val worksites = networkDataSource.getWorksitesFlagsFormDataPage( + incidentId, + pageCount, + pageIndex + 1, + updatedAtAfter = updatedAfter, + ) + + val dataCache = IncidentWorksitesSecondaryDataPageRequest( + incidentId, + requestTime, + pageIndex, + pageIndex * pageCount, + expectedCount, + worksites, + ) + + val cacheFileName = secondaryDataFileName(incidentId, pageIndex) + val cacheFile = File(context.cacheDir, cacheFileName) + cacheFile.outputStream().use { + Json.encodeToStream(dataCache, it) + } + } + + override suspend fun deleteWorksitesSecondaryData( + incidentId: Long, + pageIndex: Int, + ) { + val cacheFileName = secondaryDataFileName(incidentId, pageIndex) + deleteCacheFile(cacheFileName) + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt index 5eceb45c7..5a5d1f570 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt @@ -1,11 +1,11 @@ package com.crisiscleanup.core.data -import com.crisiscleanup.core.common.AppMemoryStats import com.crisiscleanup.core.common.LocationProvider import com.crisiscleanup.core.common.log.AppLogger -import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Worksites import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.data.model.asEntities +import com.crisiscleanup.core.data.util.IncidentDataPullStats import com.crisiscleanup.core.database.dao.IncidentDao import com.crisiscleanup.core.database.dao.WorksiteDao import com.crisiscleanup.core.database.dao.WorksiteDaoPlus @@ -13,11 +13,13 @@ import com.crisiscleanup.core.database.dao.WorksiteSyncStatDao import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds import com.crisiscleanup.core.database.model.CoordinateGridQuery import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity +import com.crisiscleanup.core.database.model.PopulatedIncidentSyncStats import com.crisiscleanup.core.database.model.SwNeBounds import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.datetime.Clock import kotlinx.datetime.Instant @@ -35,7 +37,8 @@ data class IncidentProgressPullStats( interface WorksitesFullSyncer { val fullPullStats: Flow - + val secondaryDataPullStats: Flow + val onFullDataPullComplete: Flow suspend fun sync(incidentId: Long) } @@ -48,11 +51,14 @@ class IncidentWorksitesFullSyncer @Inject constructor( private val worksiteDao: WorksiteDao, private val worksiteDaoPlus: WorksiteDaoPlus, private val worksiteSyncStatDao: WorksiteSyncStatDao, - memoryStats: AppMemoryStats, + private val deviceInspector: SyncCacheDeviceInspector, private val locationProvider: LocationProvider, - @Logger(CrisisCleanupLoggers.Worksites) private val logger: AppLogger, + private val secondaryDataSyncer: WorksitesSecondaryDataSyncer, + @Logger(Worksites) private val logger: AppLogger, ) : WorksitesFullSyncer { override val fullPullStats = MutableStateFlow(IncidentProgressPullStats()) + override val secondaryDataPullStats = secondaryDataSyncer.dataPullStats + override val onFullDataPullComplete = MutableSharedFlow() override suspend fun sync(incidentId: Long) { worksiteSyncStatDao.getIncidentSyncStats(incidentId)?.let { syncStats -> @@ -65,28 +71,28 @@ class IncidentWorksitesFullSyncer @Inject constructor( longitude = 999.0, radius = 0.0, ) - saveWorksitesFull(syncStats.entity.targetCount, fullStats) + saveWorksitesFull( + syncStats, + fullStats, + ) } } } - private val allWorksitesMemoryThreshold = 100 - private val isCapableDevice = memoryStats.availableMemory >= allWorksitesMemoryThreshold - // TODO Use connection strength to determine count private val fullSyncPageCount = 40 private val idSyncPageCount = 20 private suspend fun saveWorksitesFull( - initialSyncCount: Int, - syncStats: IncidentWorksitesFullSyncStatsEntity, + syncStats: PopulatedIncidentSyncStats, + fullStats: IncidentWorksitesFullSyncStatsEntity, ) = coroutineScope { - val incidentId = syncStats.incidentId + val incidentId = fullStats.incidentId - val locationQueryParameters = syncStats.asQueryParameters(locationProvider) - val pullAll = syncStats.syncedAt == null || locationQueryParameters.hasLocationChange - // TODO Adjust according to strength of network connection in real time - val syncPageCount = fullSyncPageCount * (if (isCapableDevice) 2 else 1) + val locationQueryParameters = fullStats.asQueryParameters(locationProvider) + val pullAll = fullStats.syncedAt == null || locationQueryParameters.hasLocationChange + val pageCountScale = if (deviceInspector.isLimitedDevice) 1 else 2 + val syncPageCount = fullSyncPageCount * pageCountScale val largeIncidentWorksitesCount = syncPageCount * 15 val worksiteCount = networkDataSource.getWorksitesCount(incidentId) @@ -135,10 +141,14 @@ class IncidentWorksitesFullSyncer @Inject constructor( } if (pagedCount >= worksiteCount) { worksiteSyncStatDao.upsert( - syncStats.copy(syncedAt = syncStartedAt), + fullStats.copy(syncedAt = syncStartedAt), ) + + onFullDataPullComplete.emit(incidentId) } } else { + secondaryDataSyncer.sync(incidentId, syncStats) + if (locationQueryParameters.hasLocation) { val isSynced = saveWorksitesAroundLocation( incidentId, @@ -149,7 +159,7 @@ class IncidentWorksitesFullSyncer @Inject constructor( } if (isSynced) { worksiteSyncStatDao.upsert( - syncStats.copy( + fullStats.copy( syncedAt = syncStartedAt, latitude = locationQueryParameters.latitude, longitude = locationQueryParameters.longitude, @@ -160,6 +170,8 @@ class IncidentWorksitesFullSyncer @Inject constructor( } else { // TODO Signal user to set sync center and radius } + + onFullDataPullComplete.emit(incidentId) } } else { // val newWorksitesCount = worksiteCount - initialSyncCount diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSecondaryDataSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSecondaryDataSyncer.kt new file mode 100644 index 000000000..f5c441aca --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSecondaryDataSyncer.kt @@ -0,0 +1,244 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.common.AppVersionProvider +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.model.asWorksiteEntity +import com.crisiscleanup.core.data.util.IncidentDataPullStats +import com.crisiscleanup.core.data.util.IncidentDataPullStatsUpdater +import com.crisiscleanup.core.database.dao.WorksiteDaoPlus +import com.crisiscleanup.core.database.dao.WorksiteSyncStatDao +import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity +import com.crisiscleanup.core.database.model.PopulatedIncidentSyncStats +import com.crisiscleanup.core.database.model.WorksiteFormDataEntity +import com.crisiscleanup.core.database.model.asExternalModel +import com.crisiscleanup.core.database.model.asSecondaryWorksiteSyncStatsEntity +import com.crisiscleanup.core.model.data.IncidentDataSyncStats +import com.crisiscleanup.core.model.data.SyncAttempt +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.model.KeyDynamicValuePair +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +interface WorksitesSecondaryDataSyncer { + val dataPullStats: Flow + + suspend fun sync( + incidentId: Long, + syncStats: PopulatedIncidentSyncStats, + ) +} + +class IncidentWorksitesSecondaryDataSyncer @Inject constructor( + private val networkDataSource: CrisisCleanupNetworkDataSource, + private val networkDataCache: WorksitesNetworkDataCache, + private val worksiteDaoPlus: WorksiteDaoPlus, + private val worksiteSyncStatDao: WorksiteSyncStatDao, + private val deviceInspector: SyncCacheDeviceInspector, + private val appVersionProvider: AppVersionProvider, + @Logger(CrisisCleanupLoggers.Worksites) private val logger: AppLogger, +) : WorksitesSecondaryDataSyncer { + override val dataPullStats = MutableStateFlow(IncidentDataPullStats()) + + private suspend fun networkWorksitesCount(incidentId: Long, updatedAfter: Instant?) = + networkDataSource.getWorksitesCount(incidentId, updatedAfter) + + private suspend fun getCleanSyncStats(incidentId: Long): IncidentDataSyncStats { + val worksitesCount = networkWorksitesCount(incidentId, null) + return IncidentDataSyncStats( + incidentId, + Clock.System.now(), + worksitesCount, + 0, + SyncAttempt(0, 0, 0), + appVersionProvider.versionCode, + ) + } + + override suspend fun sync(incidentId: Long, syncStats: PopulatedIncidentSyncStats) { + syncSecondaryData(incidentId, syncStats.secondaryStats) + } + + private suspend fun syncSecondaryData( + incidentId: Long, + secondarySyncStats: IncidentWorksitesSecondarySyncStatsEntity?, + ) { + val statsUpdater = IncidentDataPullStatsUpdater( + updatePullStats = { stats -> dataPullStats.value = stats }, + ).also { + it.beginPull(incidentId) + } + try { + var syncStats = secondarySyncStats?.asExternalModel() ?: getCleanSyncStats(incidentId) + if (syncStats.isDataVersionOutdated) { + syncStats = getCleanSyncStats(incidentId) + } + saveSecondaryWorksitesData(incidentId, syncStats, statsUpdater) + } finally { + statsUpdater.endPull() + } + } + + private suspend fun saveSecondaryWorksitesData( + incidentId: Long, + syncStats: IncidentDataSyncStats, + statsUpdater: IncidentDataPullStatsUpdater, + ) = coroutineScope { + val isDeltaPull = syncStats.isDeltaPull + val updatedAfter: Instant? + val syncCount: Int + if (isDeltaPull) { + updatedAfter = Instant.fromEpochSeconds(syncStats.syncAttempt.successfulSeconds) + syncCount = networkWorksitesCount(incidentId, updatedAfter) + } else { + updatedAfter = null + syncCount = syncStats.dataCount + } + if (syncCount <= 0) { + return@coroutineScope + } + + statsUpdater.updateDataCount(syncCount) + + statsUpdater.setPagingRequest() + + var networkPullPage = 0 + var requestingCount = 0 + // TODO Review if these page counts are optimal for secondary data + val pageCount = if (deviceInspector.isLimitedDevice) 3000 else 5000 + try { + while (requestingCount < syncCount) { + networkDataCache.saveWorksitesSecondaryData( + incidentId, + pageCount, + networkPullPage, + syncCount, + updatedAfter, + ) + networkPullPage++ + requestingCount += pageCount + + ensureActive() + + val requestedCount = requestingCount.coerceAtMost(syncCount) + statsUpdater.updateRequestedCount(requestedCount) + } + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + + logger.logException(e) + } + + worksiteSyncStatDao.upsertSecondaryStats(syncStats.asSecondaryWorksiteSyncStatsEntity()) + + var startSyncRequestTime: Instant? = null + var dbSaveCount = 0 + var deleteCacheFiles = false + for (dbSavePage in 0 until networkPullPage) { + val cachedData = networkDataCache.loadWorksitesSecondaryData( + incidentId, + dbSavePage, + syncCount, + ) ?: break + + if (startSyncRequestTime == null) { + startSyncRequestTime = cachedData.requestTime + } + + // TODO Deltas must account for deleted and/or reassigned if not inherently accounted for + + val saveData = syncStats.pagedCount < dbSaveCount + pageCount || isDeltaPull + if (saveData) { + with(cachedData.secondaryData) { + val worksitesIds = map { it.id } + val formData = map { + it.formData.map(KeyDynamicValuePair::asWorksiteEntity) + } + saveToDb( + worksitesIds, + formData, + cachedData.requestTime, + statsUpdater, + ) + } + } else { + statsUpdater.addSavedCount(pageCount) + } + + dbSaveCount += pageCount + val isSyncEnd = dbSaveCount >= syncCount + + if (saveData) { + if (isSyncEnd) { + worksiteSyncStatDao.updateSecondaryStatsSuccessful( + incidentId, + syncStats.syncStart, + syncStats.dataCount, + startSyncRequestTime, + startSyncRequestTime, + 0, + appVersionProvider.versionCode, + ) + } else if (!isDeltaPull) { + worksiteSyncStatDao.updateSecondaryStatsPaged( + incidentId, + syncStats.syncStart, + dbSaveCount, + ) + } + } + + if (isSyncEnd) { + deleteCacheFiles = true + break + } + } + + if (deleteCacheFiles) { + for (deleteCachePage in 0 until networkPullPage) { + networkDataCache.deleteWorksitesShort(incidentId, deleteCachePage) + } + } + } + + private suspend fun saveToDb( + worksiteIds: List, + formData: List>, + syncStart: Instant, + statsUpdater: IncidentDataPullStatsUpdater, + ): Int = coroutineScope { + var offset = 0 + // TODO Make configurable. Depends on the capabilities and/or OS version of the device as well. + val dbOperationLimit = 500 + val limit = dbOperationLimit.coerceAtLeast(100) + var pagedCount = 0 + while (offset < worksiteIds.size) { + val offsetEnd = (offset + limit).coerceAtMost(worksiteIds.size) + val worksiteIdsSubset = worksiteIds.slice(offset until offsetEnd) + val formDataSubset = formData.slice(offset until offsetEnd) + // Flags should have been saved by IncidentWorksitesSyncer + worksiteDaoPlus.syncFormData( + worksiteIdsSubset, + formDataSubset, + ) + + statsUpdater.addSavedCount(worksiteIdsSubset.size) + + pagedCount += worksiteIdsSubset.size + + offset += limit + + ensureActive() + } + return@coroutineScope pagedCount + } +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSyncer.kt index 730e35f9b..3a4d819b9 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSyncer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSyncer.kt @@ -1,9 +1,8 @@ package com.crisiscleanup.core.data -import com.crisiscleanup.core.common.AppMemoryStats import com.crisiscleanup.core.common.AppVersionProvider import com.crisiscleanup.core.common.log.AppLogger -import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Worksites import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.data.model.asEntity import com.crisiscleanup.core.data.util.IncidentDataPullStats @@ -45,16 +44,12 @@ class IncidentWorksitesSyncer @Inject constructor( private val networkDataCache: WorksitesNetworkDataCache, private val worksiteDaoPlus: WorksiteDaoPlus, private val worksiteSyncStatDao: WorksiteSyncStatDao, - memoryStats: AppMemoryStats, + private val deviceInspector: SyncCacheDeviceInspector, private val appVersionProvider: AppVersionProvider, - @Logger(CrisisCleanupLoggers.Worksites) private val logger: AppLogger, + @Logger(Worksites) private val logger: AppLogger, ) : WorksitesSyncer { override val dataPullStats = MutableStateFlow(IncidentDataPullStats()) - // TODO Defer to provider instead. So amount can vary according to (WifiManager) signal level or equivalent. Must track request timeouts and give feedback or adjust. - private val allWorksitesMemoryThreshold = 100 - private val isCapableDevice = memoryStats.availableMemory >= allWorksitesMemoryThreshold - override suspend fun networkWorksitesCount(incidentId: Long, updatedAfter: Instant?) = networkDataSource.getWorksitesCount(incidentId, updatedAfter) @@ -99,8 +94,7 @@ class IncidentWorksitesSyncer @Inject constructor( var networkPullPage = 0 var requestingCount = 0 - val pageCount = - if (isCapableDevice) 10000 else 3000 + val pageCount = if (deviceInspector.isLimitedDevice) 3000 else 10000 try { while (requestingCount < syncCount) { networkDataCache.saveWorksitesShort( diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/SyncCacheDeviceInspector.kt b/core/data/src/main/java/com/crisiscleanup/core/data/SyncCacheDeviceInspector.kt new file mode 100644 index 000000000..ea59b2a97 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/SyncCacheDeviceInspector.kt @@ -0,0 +1,18 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.common.AppMemoryStats +import javax.inject.Inject +import javax.inject.Singleton + +interface SyncCacheDeviceInspector { + val isLimitedDevice: Boolean +} + +@Singleton +class WorksitesSyncCacheDeviceInspector @Inject constructor( + memoryStats: AppMemoryStats, +) : SyncCacheDeviceInspector { + // TODO Account for other device properties like Wifi signal and reliability + private val allWorksitesMemoryThreshold = 100 + override val isLimitedDevice = memoryStats.availableMemory < allWorksitesMemoryThreshold +} 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 7f51d9e5a..985d84763 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 @@ -5,10 +5,14 @@ import com.crisiscleanup.core.common.NetworkMonitor import com.crisiscleanup.core.data.IncidentOrganizationsDataCache import com.crisiscleanup.core.data.IncidentOrganizationsDataFileCache import com.crisiscleanup.core.data.IncidentWorksitesFullSyncer +import com.crisiscleanup.core.data.IncidentWorksitesSecondaryDataSyncer import com.crisiscleanup.core.data.IncidentWorksitesSyncer +import com.crisiscleanup.core.data.SyncCacheDeviceInspector import com.crisiscleanup.core.data.WorksitesFullSyncer import com.crisiscleanup.core.data.WorksitesNetworkDataCache import com.crisiscleanup.core.data.WorksitesNetworkDataFileCache +import com.crisiscleanup.core.data.WorksitesSecondaryDataSyncer +import com.crisiscleanup.core.data.WorksitesSyncCacheDeviceInspector import com.crisiscleanup.core.data.WorksitesSyncer import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository @@ -197,15 +201,19 @@ interface DataModule { @InstallIn(SingletonComponent::class) interface DataInternalModule { @Binds - fun providesWorksitesNetworkDataCache( - cache: WorksitesNetworkDataFileCache, - ): WorksitesNetworkDataCache + fun bindsSyncCacheDeviceInspector(inspector: WorksitesSyncCacheDeviceInspector): SyncCacheDeviceInspector @Binds - fun providesWorksitesSyncer(syncer: IncidentWorksitesSyncer): WorksitesSyncer + fun bindsWorksitesNetworkDataCache(cache: WorksitesNetworkDataFileCache): WorksitesNetworkDataCache @Binds - fun providesWorksitesFullSyncer(syncer: IncidentWorksitesFullSyncer): WorksitesFullSyncer + fun bindsWorksitesSyncer(syncer: IncidentWorksitesSyncer): WorksitesSyncer + + @Binds + fun bindsWorksitesFullSyncer(syncer: IncidentWorksitesFullSyncer): WorksitesFullSyncer + + @Binds + fun bindsWorksitesSecondaryDataSyncer(syncer: IncidentWorksitesSecondaryDataSyncer): WorksitesSecondaryDataSyncer @Binds fun providesIncidentOrganizationsNetworkDataCache( diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt index 174e8fcb9..91b4e4f0e 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/IncidentWorksitesPageRequest.kt @@ -1,20 +1,29 @@ package com.crisiscleanup.core.data.model +import com.crisiscleanup.core.network.model.NetworkFlagsFormData import com.crisiscleanup.core.network.model.NetworkIncidentOrganization import com.crisiscleanup.core.network.model.NetworkWorksitePage import kotlinx.datetime.Instant import kotlinx.serialization.Serializable +interface IncidentCacheDataPageRequest { + val incidentId: Long + val requestTime: Instant + val page: Int + val startCount: Int + val totalCount: Int +} + @Serializable data class IncidentWorksitesPageRequest( - val incidentId: Long, - val requestTime: Instant, - val page: Int, + override val incidentId: Long, + override val requestTime: Instant, + override val page: Int, // Indicates the number of records coming before this data - val startCount: Int, - val totalCount: Int, + override val startCount: Int, + override val totalCount: Int, val worksites: List, -) +) : IncidentCacheDataPageRequest @Serializable data class IncidentOrganizationsPageRequest( @@ -23,3 +32,14 @@ data class IncidentOrganizationsPageRequest( val totalCount: Int, val organizations: List, ) + +@Serializable +data class IncidentWorksitesSecondaryDataPageRequest( + override val incidentId: Long, + override val requestTime: Instant, + override val page: Int, + // Indicates the number of records coming before this data + override val startCount: Int, + override val totalCount: Int, + val secondaryData: List, +) : IncidentCacheDataPageRequest diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt index fccfcbb30..05fdbe7fa 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt @@ -16,16 +16,18 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import javax.inject.Inject import javax.inject.Singleton interface CasesFilterRepository { val casesFilters: CasesFilter - val casesFiltersLocation: StateFlow> + val casesFiltersLocation: StateFlow> val filtersCount: Flow fun changeFilters(filters: CasesFilter) fun updateWorkTypeFilters(workTypes: Collection) + fun reapplyFilters() } @Singleton @@ -38,14 +40,17 @@ class CrisisCleanupCasesFilterRepository @Inject constructor( private val _isLoading = MutableStateFlow(true) val isLoading: StateFlow = _isLoading + private val applyFilterTimestamp = MutableStateFlow(0L) + override val casesFiltersLocation = combine( dataSource.casesFilters, permissionManager.hasLocationPermission, - ::Pair, + applyFilterTimestamp, + ::Triple, ) .stateIn( scope = externalScope, - initialValue = Pair(CasesFilter(), false), + initialValue = Triple(CasesFilter(), false, 0L), started = SharingStarted.WhileSubscribed(), ) override val casesFilters: CasesFilter @@ -64,4 +69,10 @@ class CrisisCleanupCasesFilterRepository @Inject constructor( override fun updateWorkTypeFilters(workTypes: Collection) { // TODO Update work types removing non-matching } + + override fun reapplyFilters() { + if (casesFilters.changeCount > 0) { + applyFilterTimestamp.value = Clock.System.now().epochSeconds + } + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 7ab8a3d0d..12061b202 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -82,6 +82,8 @@ class OfflineFirstWorksitesRepository @Inject constructor( override val syncWorksitesFullIncidentId = MutableStateFlow(EmptyIncident.id) override val incidentDataPullStats = worksitesSyncer.dataPullStats + override val incidentSecondaryDataPullStats = worksitesFullSyncer.secondaryDataPullStats + override val onIncidentDataPullComplete = worksitesFullSyncer.onFullDataPullComplete override val isDeterminingWorksitesCount = MutableStateFlow(false) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/util/IncidentDataPullReporter.kt b/core/data/src/main/java/com/crisiscleanup/core/data/util/IncidentDataPullReporter.kt index e2d358bcd..e90ea8aa6 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/util/IncidentDataPullReporter.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/util/IncidentDataPullReporter.kt @@ -10,6 +10,8 @@ import kotlin.time.Duration.Companion.seconds interface IncidentDataPullReporter { val incidentDataPullStats: Flow + val incidentSecondaryDataPullStats: Flow + val onIncidentDataPullComplete: Flow } data class IncidentDataPullStats( diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt index fa9a3b7f1..f642bde7f 100644 --- a/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt @@ -62,6 +62,7 @@ class AppPreferencesRepositoryTest { languageKey = "", tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, + hideGettingStartedVideo = false, ), repository.userPreferences.first(), ) diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/40.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/40.json new file mode 100644 index 000000000..ef2e0b421 --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/40.json @@ -0,0 +1,2810 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "ae7067673f8833bf4e9a4e300f6a65e4", + "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`)" + } + ], + "foreignKeys": [] + }, + { + "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 '', `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, 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": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "activePhoneNumber", + "columnName": "active_phone_number", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "turnOnRelease", + "columnName": "turn_on_release", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "is_archived", + "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)" + } + ], + "foreignKeys": [] + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "placeholder", + "columnName": "placeholder", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "readOnlyBreakGlass", + "columnName": "read_only_break_glass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesDefaultJson", + "columnName": "values_default_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isCheckboxDefaultTrue", + "columnName": "is_checkbox_default_true", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "orderLabel", + "columnName": "order_label", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "validation", + "columnName": "validation", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "recurDefault", + "columnName": "recur_default", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "'0'" + }, + { + "fieldPath": "valuesJson", + "columnName": "values_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": false, + "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", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentKeyNonNull", + "columnName": "parent_key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "selectToggleWorkType", + "columnName": "selected_toggle_work_type", + "affinity": "TEXT", + "notNull": false, + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, `phone2` 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, `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", + "notNull": false + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "favoriteId", + "columnName": "favorite_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyWorkTypeType", + "columnName": "key_work_type_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "keyWorkTypeOrgClaim", + "columnName": "key_work_type_org", + "affinity": "INTEGER", + "notNull": false + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "plusCode", + "columnName": "plus_code", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "postalCode", + "columnName": "postal_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportedBy", + "columnName": "reported_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "svi", + "columnName": "svi", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "what3Words", + "columnName": "what3Words", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "orgClaim", + "columnName": "claimed_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextRecurAt", + "columnName": "next_recur_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recur", + "columnName": "recur", + "affinity": "TEXT", + "notNull": false + }, + { + "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`)" + } + ], + "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" + ] + }, + "indices": [], + "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", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHighPriority", + "columnName": "is_high_priority", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "reasonT", + "columnName": "reason_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedAction", + "columnName": "requested_action", + "affinity": "TEXT", + "notNull": false, + "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", + "notNull": false + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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)" + } + ], + "foreignKeys": [] + }, + { + "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)" + } + ], + "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", + "notNull": false + }, + { + "fieldPath": "secondaryLocation", + "columnName": "secondary_location", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, 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 + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "largeThumbnailUrl", + "columnName": "large_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeContentType", + "columnName": "mime_content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallThumbnailUrl", + "columnName": "small_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "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_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": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "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": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + } + ], + "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", + "notNull": false + }, + { + "fieldPath": "patientId", + "columnName": "patient_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientLabelT", + "columnName": "patient_label_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientLocationName", + "columnName": "patient_location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientNameT", + "columnName": "patient_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientReasonT", + "columnName": "patient_reason_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientStatusNameT", + "columnName": "patient_status_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientCaseNumber", + "columnName": "recipient_case_number", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientId", + "columnName": "recipient_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recipientName", + "columnName": "recipient_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientNameT", + "columnName": "recipient_name_t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "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" + ] + } + ] + }, + { + "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_b_BEFORE_UPDATE BEFORE UPDATE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_BEFORE_DELETE BEFORE DELETE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_UPDATE AFTER UPDATE ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_INSERT AFTER INSERT ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END" + ], + "tableName": "worksite_text_fts_b", + "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, `phone1` TEXT NOT NULL, `phone2` TEXT NOT NULL, 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": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "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" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + } + ], + "views": [], + "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, 'ae7067673f8833bf4e9a4e300f6a65e4')" + ] + } +} \ No newline at end of file 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 a6d2a1c75..e8bb4b44b 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 @@ -42,6 +42,7 @@ import com.crisiscleanup.core.database.model.IncidentLocationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.model.IncidentOrganizationSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity +import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.LanguageTranslationEntity import com.crisiscleanup.core.database.model.LocationEntity import com.crisiscleanup.core.database.model.NetworkFileEntity @@ -102,8 +103,9 @@ import com.crisiscleanup.core.database.util.InstantConverter CaseHistoryEventAttrEntity::class, PersonOrganizationCrossRef::class, WorksiteTextFtsEntity::class, + IncidentWorksitesSecondarySyncStatsEntity::class, ], - version = 39, + version = 40, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -143,6 +145,7 @@ import com.crisiscleanup.core.database.util.InstantConverter AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), AutoMigration(from = 38, to = 39), + AutoMigration(from = 39, to = 40), ], exportSchema = true, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index 65316f5f7..e6b734da0 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -286,6 +286,33 @@ class WorksiteDaoPlus @Inject constructor( } } + suspend fun syncFormData( + networkWorksiteIds: List, + formDatas: List>, + ) { + throwSizeMismatch(networkWorksiteIds.size, formDatas.size, "form-data") + + val worksiteIdsSet = networkWorksiteIds.toSet() + db.withTransaction { + val modifiedAtLookup = getWorksiteLocalModifiedAt(worksiteIdsSet) + + val worksiteDao = db.worksiteDao() + val formDataDao = db.worksiteFormDataDao() + formDatas.forEachIndexed { i, formData -> + val networkWorksiteId = networkWorksiteIds[i] + val modifiedAt = modifiedAtLookup[networkWorksiteId] + val isLocallyModified = modifiedAt?.isLocallyModified ?: false + if (!isLocallyModified) { + val worksiteId = worksiteDao.getWorksiteId(networkWorksiteId) + val fieldKeys = formData.map(WorksiteFormDataEntity::fieldKey) + formDataDao.deleteUnspecifiedKeys(worksiteId, fieldKeys) + val updatedFormData = formData.map { it.copy(worksiteId = worksiteId) } + formDataDao.upsert(updatedFormData) + } + } + } + } + suspend fun syncWorksite( entities: WorksiteEntities, syncedAt: Instant, diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteSyncStatDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteSyncStatDao.kt index bbc6527e1..b814b1050 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteSyncStatDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteSyncStatDao.kt @@ -5,6 +5,7 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity +import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.PopulatedIncidentSyncStats import com.crisiscleanup.core.database.model.WorksiteSyncStatsEntity import kotlinx.datetime.Instant @@ -105,4 +106,44 @@ interface WorksiteSyncStatDao { incidentId: Long, radius: Double, ) + + @Upsert + fun upsertSecondaryStats(stats: IncidentWorksitesSecondarySyncStatsEntity) + + @Transaction + @Query( + """ + UPDATE OR IGNORE incident_worksites_secondary_sync_stats + SET paged_count=:pagedCount + WHERE incident_id=:incidentId AND sync_start=:syncStart + """, + ) + fun updateSecondaryStatsPaged( + incidentId: Long, + syncStart: Instant, + pagedCount: Int, + ) + + @Transaction + @Query( + """ + UPDATE OR IGNORE incident_worksites_secondary_sync_stats + SET + paged_count =:pagedCount, + successful_sync =:successfulSync, + attempted_sync =:attemptedSync, + attempted_counter =:attemptedCounter, + app_build_version_code=:appBuildVersionCode + WHERE incident_id=:incidentId AND sync_start=:syncStart + """, + ) + fun updateSecondaryStatsSuccessful( + incidentId: Long, + syncStart: Instant, + pagedCount: Int, + successfulSync: Instant?, + attemptedSync: Instant?, + attemptedCounter: Int, + appBuildVersionCode: Long, + ) } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncidentSyncStats.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncidentSyncStats.kt index 23e108bc2..8fdbbf68b 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncidentSyncStats.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncidentSyncStats.kt @@ -11,6 +11,11 @@ data class PopulatedIncidentSyncStats( entityColumn = "incident_id", ) val fullStats: IncidentWorksitesFullSyncStatsEntity?, + @Relation( + parentColumn = "incident_id", + entityColumn = "incident_id", + ) + val secondaryStats: IncidentWorksitesSecondarySyncStatsEntity?, ) { fun isShortSynced() = with(entity) { successfulSync != null && pagedCount >= targetCount diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteSyncStatsEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteSyncStatsEntity.kt index bfde7104c..3a57806f6 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteSyncStatsEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteSyncStatsEntity.kt @@ -62,6 +62,37 @@ data class IncidentWorksitesFullSyncStatsEntity( val radius: Double, ) +@Entity( + "incident_worksites_secondary_sync_stats", + foreignKeys = [ + ForeignKey( + entity = WorksiteSyncStatsEntity::class, + parentColumns = ["incident_id"], + childColumns = ["incident_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class IncidentWorksitesSecondarySyncStatsEntity( + @PrimaryKey + @ColumnInfo("incident_id") + val incidentId: Long, + @ColumnInfo("sync_start", defaultValue = "0") + val syncStart: Instant, + @ColumnInfo("target_count") + val targetCount: Int, + @ColumnInfo("paged_count", defaultValue = "0") + val pagedCount: Int, + @ColumnInfo("successful_sync") + val successfulSync: Instant?, + @ColumnInfo("attempted_sync") + val attemptedSync: Instant?, + @ColumnInfo("attempted_counter") + val attemptedCounter: Int, + @ColumnInfo("app_build_version_code", defaultValue = "0") + val appBuildVersionCode: Long, +) + fun WorksiteSyncStatsEntity.asExternalModel() = IncidentDataSyncStats( incidentId = incidentId, syncStart = syncStart, @@ -93,3 +124,36 @@ fun IncidentDataSyncStats.asWorksiteSyncStatsEntity() = WorksiteSyncStatsEntity( attemptedCounter = syncAttempt.attemptedCounter, appBuildVersionCode = appBuildVersionCode, ) + +fun IncidentDataSyncStats.asSecondaryWorksiteSyncStatsEntity() = + IncidentWorksitesSecondarySyncStatsEntity( + incidentId = incidentId, + syncStart = syncStart, + targetCount = dataCount, + pagedCount = pagedCount, + successfulSync = if (syncAttempt.successfulSeconds <= 0) { + null + } else { + Instant.fromEpochSeconds(syncAttempt.successfulSeconds) + }, + attemptedSync = if (syncAttempt.attemptedSeconds <= 0) { + null + } else { + Instant.fromEpochSeconds(syncAttempt.attemptedSeconds) + }, + attemptedCounter = syncAttempt.attemptedCounter, + appBuildVersionCode = appBuildVersionCode, + ) + +fun IncidentWorksitesSecondarySyncStatsEntity.asExternalModel() = IncidentDataSyncStats( + incidentId = incidentId, + syncStart = syncStart, + dataCount = targetCount, + pagedCount = pagedCount, + syncAttempt = SyncAttempt( + successfulSync?.epochSeconds ?: 0, + attemptedSync?.epochSeconds ?: 0, + attemptedCounter, + ), + appBuildVersionCode = appBuildVersionCode, +) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt index a4879a959..112c1de73 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt @@ -2,9 +2,10 @@ package com.crisiscleanup.core.designsystem.icon import androidx.annotation.DrawableRes import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.ArrowBackIos import androidx.compose.material.icons.automirrored.filled.HelpOutline import androidx.compose.material.icons.filled.Add -import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Check @@ -49,7 +50,8 @@ private val icons = Icons.Default object CrisisCleanupIcons { val Account = icons.PersonOutline val Add = icons.Add - val ArrowBack = icons.ArrowBackIosNew + val ArrowBack = Icons.AutoMirrored.Filled.ArrowBackIos + val ArrowBack2 = Icons.AutoMirrored.Filled.ArrowBack val ArrowDropDown = icons.ArrowDropDown val Calendar = icons.CalendarMonth val CaretUp = icons.KeyboardArrowUp diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentDataSyncStats.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentDataSyncStats.kt index fc5660bcd..21fb56119 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentDataSyncStats.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentDataSyncStats.kt @@ -30,7 +30,7 @@ data class IncidentDataSyncStats( /** * Number of data (pages) pulled and saved locally during first sync * - * This is the same units as [dataCount]. + * This has the same units as [dataCount]. */ val pagedCount: Int = 0, /** 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 06e3b7cad..3edaaf17b 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 @@ -3,6 +3,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.NetworkCountResult +import com.crisiscleanup.core.network.model.NetworkFlagsFormData import com.crisiscleanup.core.network.model.NetworkIncident import com.crisiscleanup.core.network.model.NetworkIncidentOrganization import com.crisiscleanup.core.network.model.NetworkLanguageDescription @@ -76,6 +77,13 @@ interface CrisisCleanupNetworkDataSource { updatedAtAfter: Instant? = null, ): List + suspend fun getWorksitesFlagsFormDataPage( + incidentId: Long, + pageCount: Int, + pageOffset: Int? = null, + updatedAtAfter: Instant? = null, + ): List + suspend fun getLocationSearchWorksites( incidentId: Long, q: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataResult.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataResult.kt new file mode 100644 index 000000000..16bd172ff --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataResult.kt @@ -0,0 +1,29 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkFlagsFormDataResult( + val errors: List? = null, + val count: Int? = null, + val results: List? = null, +) + +@Serializable +data class NetworkFlagsFormData( + val id: Long, + @SerialName("case_number") + val caseNumber: String, + @SerialName("form_data") + val formData: List, + val flags: List, +) { + @Serializable + data class NetworkFlag( + @SerialName("is_high_priority") + val isHighPriority: Boolean?, + @SerialName("reason_t") + val reasonT: String?, + ) +} 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 0354902f0..fbcea248b 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 @@ -4,6 +4,8 @@ import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkAccountProfileResult import com.crisiscleanup.core.network.model.NetworkCaseHistoryResult import com.crisiscleanup.core.network.model.NetworkCountResult +import com.crisiscleanup.core.network.model.NetworkFlagsFormData +import com.crisiscleanup.core.network.model.NetworkFlagsFormDataResult import com.crisiscleanup.core.network.model.NetworkIncidentResult import com.crisiscleanup.core.network.model.NetworkIncidentsResult import com.crisiscleanup.core.network.model.NetworkLanguageTranslationResult @@ -233,6 +235,15 @@ private interface DataSourceApi { @TokenAuthenticationHeader @GET("incident_requests") suspend fun getRedeployRequests(): NetworkRedeployRequestsResult + + @TokenAuthenticationHeader + @GET("worksites_data_flags") + suspend fun getWorksitesFlagsFormData( + @Query("incident") incidentId: Long, + @Query("limit") limit: Int, + @Query("offset") offset: Int?, + @Query("updated_at__gt") updatedAtAfter: Instant?, + ): NetworkFlagsFormDataResult } private val worksiteCoreDataFields = listOf( @@ -353,6 +364,22 @@ class DataApiClient @Inject constructor( return result.results ?: emptyList() } + override suspend fun getWorksitesFlagsFormDataPage( + incidentId: Long, + pageCount: Int, + pageOffset: Int?, + updatedAtAfter: Instant?, + ): List { + val result = networkApi.getWorksitesFlagsFormData( + incidentId, + limit = pageCount, + offset = if ((pageOffset ?: 0) <= 1) null else pageOffset!! * pageCount, + updatedAtAfter, + ) + result.errors?.tryThrowException() + return result.results ?: emptyList() + } + private val locationSearchFields = listOf( "id", "name", diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataTest.kt new file mode 100644 index 000000000..93bcd7b12 --- /dev/null +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataTest.kt @@ -0,0 +1,103 @@ +package com.crisiscleanup.core.network.model + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class NetworkFlagsFormDataTest { + @Test + fun getWorksitesCount() { + val result = + TestUtil.decodeResource("/getFlagsFormDataSuccess.json") + + assertNull(result.errors) + assertEquals(20, result.count) + + val entry = result.results!![2] + + val expected = NetworkFlagsFormData( + id = 220667, + caseNumber = "V2026", + formData = listOf( + KeyDynamicValuePair( + key = "cross_street", + value = DynamicValue(valueString = "Easy St"), + ), + KeyDynamicValuePair( + key = "time_to_call", + value = DynamicValue(valueString = "anytime"), + ), + KeyDynamicValuePair( + key = "older_than_60", + value = DynamicValue(valueString = "", isBoolean = true, valueBoolean = true), + ), + KeyDynamicValuePair( + key = "special_needs", + value = DynamicValue(valueString = "disabled"), + ), + KeyDynamicValuePair( + key = "primary_language", + value = DynamicValue(valueString = "formOptions.english"), + ), + KeyDynamicValuePair( + key = "residence_type", + value = DynamicValue(valueString = "formOptions.primary_living_in_home"), + ), + KeyDynamicValuePair( + key = "dwelling_type", + value = DynamicValue(valueString = "formOptions.house"), + ), + KeyDynamicValuePair( + key = "rent_or_own", + value = DynamicValue(valueString = "formOptions.rent"), + ), + KeyDynamicValuePair( + key = "insurance_home_rent", + value = DynamicValue(valueString = "", isBoolean = true, valueBoolean = true), + ), + KeyDynamicValuePair( + key = "water_status", + value = DynamicValue(valueString = "formOptions.on"), + ), + KeyDynamicValuePair( + key = "power_status", + value = DynamicValue(valueString = "formOptions.off"), + ), + KeyDynamicValuePair( + key = "tree_info", + value = DynamicValue(valueString = "", isBoolean = true, valueBoolean = true), + ), + KeyDynamicValuePair( + key = "num_wide_trees", + value = DynamicValue(valueString = "formOptions.four"), + ), + KeyDynamicValuePair( + key = "debris_info", + value = DynamicValue(valueString = "", isBoolean = true, valueBoolean = true), + ), + KeyDynamicValuePair( + key = "debris_description", + value = DynamicValue(valueString = "carport gone as well as fences"), + ), + KeyDynamicValuePair( + key = "habitable", + value = DynamicValue(valueString = "formOptions.yes"), + ), + KeyDynamicValuePair( + key = "debris_status", + value = DynamicValue(valueString = "formOptions.piled_on_public_right_of_way"), + ), + KeyDynamicValuePair( + key = "prepared_by", + value = DynamicValue(valueString = "Mike DeLoach"), + ), + KeyDynamicValuePair( + key = "work_without_resident", + value = DynamicValue(valueString = "", isBoolean = true, valueBoolean = true), + ), + ), + flags = emptyList(), + ) + assertEquals(expected, entry) + } +} diff --git a/core/network/src/test/resources/getFlagsFormDataSuccess.json b/core/network/src/test/resources/getFlagsFormDataSuccess.json new file mode 100644 index 000000000..0d72e889a --- /dev/null +++ b/core/network/src/test/resources/getFlagsFormDataSuccess.json @@ -0,0 +1,1501 @@ +{ + "count": 20, + "results": [ + { + "id": 220642, + "case_number": "V2003", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.shingled_roof" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.slight_pitch" + }, + { + "field_key": "tarps_needed", + "field_value": "4" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.one" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220657, + "case_number": "V2016", + "form_data": [ + { + "field_key": "cross_street", + "field_value": "Palmetto Dr." + }, + { + "field_key": "time_to_call", + "field_value": "anytime" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "special_needs", + "field_value": "victim is disabled" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.rent" + }, + { + "field_key": "insurance_home_rent", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.one" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.piled_on_public_right_of_way" + }, + { + "field_key": "prepared_by", + "field_value": "Mike DeLoach" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.shingled_roof" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.flat" + }, + { + "field_key": "tarps_needed", + "field_value": "2" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.two" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220667, + "case_number": "V2026", + "form_data": [ + { + "field_key": "cross_street", + "field_value": "Easy St" + }, + { + "field_key": "time_to_call", + "field_value": "anytime" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "disabled" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.rent" + }, + { + "field_key": "insurance_home_rent", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.four" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "carport gone as well as fences" + }, + { + "field_key": "habitable", + "field_value": "formOptions.yes" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.piled_on_public_right_of_way" + }, + { + "field_key": "prepared_by", + "field_value": "Mike DeLoach" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220681, + "case_number": "V2037", + "form_data": [ + { + "field_key": "special_needs", + "field_value": "homeowner is injured" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "veteran", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.mobile_home" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.metal_roof" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.flat" + }, + { + "field_key": "help_install_tarp", + "field_value": "true" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.one" + }, + { + "field_key": "tarps_needed", + "field_value": "4" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "interior_debris_removal", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "mattress " + } + ], + "flags": [] + }, + { + "id": 220672, + "case_number": "V2029", + "form_data": [ + { + "field_key": "time_to_call", + "field_value": "anytime" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.second_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "muck_out_info", + "field_value": "true" + }, + { + "field_key": "flood_height_select", + "field_value": "formOptions.above_the_ceiling" + }, + { + "field_key": "floors_affected", + "field_value": "formOptions.ground_floor_only" + }, + { + "field_key": "ceiling_water_damage", + "field_value": "true" + }, + { + "field_key": "carpet_removal", + "field_value": "true" + }, + { + "field_key": "appliance_removal", + "field_value": "true" + }, + { + "field_key": "heavy_item_removal", + "field_value": "true" + }, + { + "field_key": "rebuild_info", + "field_value": "true" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "needs_visual", + "field_value": "true" + }, + { + "field_key": "prepared_by", + "field_value": "JT McWilliams" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "possible_asbestos", + "field_value": "true" + }, + { + "field_key": "other_hazards", + "field_value": "1939 built house. possible lead base paint" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.piled_on_property" + }, + { + "field_key": "special_needs", + "field_value": "Resident having major trouble w/internet--having trouble verifying as cannot link to even FB\nOfficials have not opened Ft Meyers Beach/Estero Island for residents " + } + ], + "flags": [ + { + "reason_t": "flag.worksite_high_priority", + "is_high_priority": true + } + ] + }, + { + "id": 220675, + "case_number": "V2032", + "form_data": [ + { + "field_key": "special_needs", + "field_value": "Downstairs flooded 3 children, they were evacuated. No power out still.\nNeed Mucking out. Not sure about roof or debris in yard. A neighbor has helped a little , but they still need. Please call so she can meet you there. She will have more assessments ready when you get there." + }, + { + "field_key": "muck_out_info", + "field_value": "true" + }, + { + "field_key": "flood_height_select", + "field_value": "formOptions.six_12in" + }, + { + "field_key": "floors_affected", + "field_value": "formOptions.ground_floor_only" + }, + { + "field_key": "carpet_removal", + "field_value": "true" + }, + { + "field_key": "tile_removal", + "field_value": "true" + }, + { + "field_key": "drywall_removal", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220695, + "case_number": "V2051", + "form_data": [ + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_displaced_from_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "gas_status", + "field_value": "formOptions.off" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "pool_cage", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": " i explained what criis cleanup does and she is getting help from other sources and says it is ok to close the cirsis case since her needs are so involved, too much for volunteers to help with she says" + } + ], + "flags": [] + }, + { + "id": 241710, + "case_number": "VQCNF2", + "form_data": [ + { + "field_key": "cross_street", + "field_value": "Canal St, Fort Meyers, FL" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_trees_down", + "field_value": "formOptions.five" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.zero" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.untouched" + } + ], + "flags": [ + { + "reason_t": "flag.worksite_wrong_location", + "is_high_priority": null + } + ] + }, + { + "id": 220688, + "case_number": "V2056", + "form_data": [ + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "shingles, lanai roof caved in, large branches from trees to curb, door from lanai, hurricane shutters to curb" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.one" + }, + { + "field_key": "tarps_needed", + "field_value": "1" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "76 y/o woman, retired physician, lives alone" + }, + { + "field_key": "first_responder", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "shingles, lanai roof caved in, large branches from trees to curb, door from lanai, hurricane shutters to curb" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.one" + }, + { + "field_key": "tarps_needed", + "field_value": "1" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "76 y/o woman, retired physician, lives alone" + }, + { + "field_key": "first_responder", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220730, + "case_number": "V2089", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "80s, stents and medication prevent him from doing it himself" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_trees_down", + "field_value": "formOptions.two" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.two" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220696, + "case_number": "V2052", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "Husband over 60, Mobile Home park. They're not sure how extensive the damage is, please call her before going and she will have someone there to help you." + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "muck_out_info", + "field_value": "true" + }, + { + "field_key": "flood_height_select", + "field_value": "formOptions.three_6in" + }, + { + "field_key": "floors_affected", + "field_value": "formOptions.ground_floor_only" + }, + { + "field_key": "ceiling_water_damage", + "field_value": "true" + }, + { + "field_key": "carpet_removal", + "field_value": "true" + }, + { + "field_key": "drywall_removal", + "field_value": "true" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.metal_roof" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.medium_pitch" + }, + { + "field_key": "help_install_tarp", + "field_value": "true" + }, + { + "field_key": "tarps_needed", + "field_value": "4" + }, + { + "field_key": "num_stories", + "field_value": "formOptions.one" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "interior_debris_removal", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "other_hazards", + "field_value": "Working on clearing roads, there may be glass, nails, etc." + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220701, + "case_number": "V2061", + "form_data": [ + { + "field_key": "cross_street", + "field_value": "near Florida School for the Deaf and Blind" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "insured", + "field_value": "true" + }, + { + "field_key": "insurance_home_rent", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "gas_status", + "field_value": "formOptions.on" + }, + { + "field_key": "power_status", + "field_value": "formOptions.on" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "Mulch needs to be moved back into garden. Resident is too old and weak to do it," + }, + { + "field_key": "habitable", + "field_value": "formOptions.yes" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.untouched" + }, + { + "field_key": "veteran", + "field_value": "true" + }, + { + "field_key": "prepared_by", + "field_value": "Edward Mayle" + }, + { + "field_key": "do_not_work_before", + "field_value": "anytime" + } + ], + "flags": [] + }, + { + "id": 220707, + "case_number": "V2067", + "form_data": [ + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "muck_out_info", + "field_value": "true" + }, + { + "field_key": "flood_height_select", + "field_value": "formOptions.four_5ft" + }, + { + "field_key": "floors_affected", + "field_value": "formOptions.ground_floor_only" + }, + { + "field_key": "appliance_removal", + "field_value": "true" + }, + { + "field_key": "heavy_item_removal", + "field_value": "true" + }, + { + "field_key": "drywall_removal", + "field_value": "true" + }, + { + "field_key": "carpet_removal", + "field_value": "true" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.metal_roof" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.medium_pitch" + }, + { + "field_key": "tarps_needed", + "field_value": "2-3" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + } + ], + "flags": [] + }, + { + "id": 220722, + "case_number": "V2081", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "prepared_by", + "field_value": "jennifer cashion" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "Needs help getting shutters down.\nNo power. Please call instead of texting and she will be there if she can get." + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_displaced_from_home" + }, + { + "field_key": "num_trees_down", + "field_value": "formOptions.zero" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "power_status", + "field_value": "formOptions.on" + }, + { + "field_key": "debris_description", + "field_value": "one palm still standing leaning at side towards house, dead needs to come down. \nCalled back 10/4/22 still needs help.\nCalled back 10/27/22 to ask for removing a wooden fence." + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + } + ], + "flags": [ + { + "reason_t": "flag.worksite_high_priority", + "is_high_priority": true + } + ] + }, + { + "id": 220715, + "case_number": "V2074", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "Husband and wife in 80's and had a recent heart attack" + }, + { + "field_key": "veteran", + "field_value": "true" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.one" + }, + { + "field_key": "other_hazards", + "field_value": "Tree is leaning on a palm tree and might come down soon" + }, + { + "field_key": "cross_street", + "field_value": "Daniel ane Treeline - Summerset at Plantation" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + } + ], + "flags": [] + }, + { + "id": 220716, + "case_number": "V2075", + "form_data": [ + { + "field_key": "children_in_home", + "field_value": "true" + }, + { + "field_key": "first_responder", + "field_value": "true" + }, + { + "field_key": "veteran", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.rent" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "insurance_home_rent", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "Roof shingles" + }, + { + "field_key": "nonvegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "vegitative_debris_removal", + "field_value": "true" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + } + ], + "flags": [] + }, + { + "id": 220719, + "case_number": "V2078", + "form_data": [ + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "veteran", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "gas_status", + "field_value": "formOptions.off" + }, + { + "field_key": "muck_out_info", + "field_value": "true" + }, + { + "field_key": "drywall_removal", + "field_value": "true" + }, + { + "field_key": "tile_removal", + "field_value": "true" + }, + { + "field_key": "hardwood_floor_removal", + "field_value": "true" + }, + { + "field_key": "heavy_item_removal", + "field_value": "true" + }, + { + "field_key": "appliance_removal", + "field_value": "true" + }, + { + "field_key": "floors_affected", + "field_value": "formOptions.ground_floor_only" + }, + { + "field_key": "flood_height_select", + "field_value": "formOptions.twelve_18in" + }, + { + "field_key": "habitable", + "field_value": "formOptions.no" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "Husband has cancer; needs help" + } + ], + "flags": [] + }, + { + "id": 220725, + "case_number": "V2084", + "form_data": [ + { + "field_key": "cross_street", + "field_value": "on a hill" + }, + { + "field_key": "time_to_call", + "field_value": "any time" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + }, + { + "field_key": "water_status", + "field_value": "formOptions.off" + }, + { + "field_key": "power_status", + "field_value": "formOptions.off" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.one" + }, + { + "field_key": "num_trees_down", + "field_value": "formOptions.one" + }, + { + "field_key": "debris_description", + "field_value": "tree debris only" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + } + ], + "flags": [] + }, + { + "id": 220731, + "case_number": "V2090", + "form_data": [ + { + "field_key": "children_in_home", + "field_value": "true" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.mobile_home" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_displaced_from_home" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.rent" + }, + { + "field_key": "power_status", + "field_value": "formOptions.on" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.five" + }, + { + "field_key": "num_trees_down", + "field_value": "formOptions.five" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "metal from roofing" + }, + { + "field_key": "tarping_info", + "field_value": "true" + }, + { + "field_key": "house_roof_damage", + "field_value": "true" + }, + { + "field_key": "roof_type", + "field_value": "formOptions.shingled_roof" + }, + { + "field_key": "tarps_needed", + "field_value": "2" + }, + { + "field_key": "roof_pitch", + "field_value": "formOptions.flat" + }, + { + "field_key": "work_without_resident", + "field_value": "true" + } + ], + "flags": [] + }, + { + "id": 220752, + "case_number": "V2109", + "form_data": [ + { + "field_key": "tree_info", + "field_value": "true" + }, + { + "field_key": "num_wide_trees", + "field_value": "formOptions.zero" + }, + { + "field_key": "debris_info", + "field_value": "true" + }, + { + "field_key": "debris_description", + "field_value": "tree debris, branches & limbs" + }, + { + "field_key": "time_to_call", + "field_value": "Anytime" + }, + { + "field_key": "older_than_60", + "field_value": "true" + }, + { + "field_key": "special_needs", + "field_value": "disabled" + }, + { + "field_key": "debris_status", + "field_value": "formOptions.untouched" + }, + { + "field_key": "primary_language", + "field_value": "formOptions.english" + }, + { + "field_key": "residence_type", + "field_value": "formOptions.primary_living_in_home" + }, + { + "field_key": "dwelling_type", + "field_value": "formOptions.house" + }, + { + "field_key": "rent_or_own", + "field_value": "formOptions.own" + }, + { + "field_key": "water_status", + "field_value": "formOptions.on" + }, + { + "field_key": "power_status", + "field_value": "formOptions.on" + }, + { + "field_key": "habitable", + "field_value": "formOptions.yes" + } + ], + "flags": [ + { + "reason_t": "flag.worksite_high_priority", + "is_high_priority": true + } + ] + } + ] +} \ No newline at end of file diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt index c9ae79850..975c33ab5 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt @@ -7,8 +7,6 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -20,6 +18,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.CrisisCleanupLogoRow +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.DayNightPreviews import com.crisiscleanup.core.designsystem.theme.LocalFontStyles @@ -29,10 +28,13 @@ import com.crisiscleanup.core.designsystem.theme.primaryBlueColor import com.crisiscleanup.feature.authentication.R @Composable -internal fun ConditionalErrorMessage(errorMessage: String) { +internal fun ConditionalErrorMessage( + errorMessage: String, + testTagPrefix: String, +) { if (errorMessage.isNotEmpty()) { Text( - modifier = fillWidthPadded, + modifier = fillWidthPadded.testTag("${testTagPrefix}Error"), text = errorMessage, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, @@ -92,7 +94,7 @@ fun LoginWithDifferentMethod( verticalAlignment = Alignment.CenterVertically, ) { Icon( - Icons.AutoMirrored.Filled.ArrowBack, + CrisisCleanupIcons.ArrowBack2, contentDescription = text, tint = actionLinkColor, modifier = Modifier diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt index e5eacaff2..169dc13a4 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt @@ -121,7 +121,7 @@ private fun LoginWithEmailScreen( ) val authErrorMessage by viewModel.errorMessage - ConditionalErrorMessage(authErrorMessage) + ConditionalErrorMessage(authErrorMessage, "emailLogin") val isNotBusy by viewModel.isNotAuthenticating.collectAsStateWithLifecycle() diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt index 48c3b16f8..a51de5cd5 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt @@ -146,7 +146,7 @@ private fun LoginWithPhoneScreen( style = LocalFontStyles.current.header1, ) - ConditionalErrorMessage(viewModel.errorMessage) + ConditionalErrorMessage(viewModel.errorMessage, "phoneLogin") val isRequestingCode by viewModel.isRequestingCode.collectAsStateWithLifecycle() val isNotBusy = !isRequestingCode @@ -226,7 +226,7 @@ private fun ColumnScope.VerifyPhoneCodeScreen( onAction = onBack, ) - ConditionalErrorMessage(viewModel.errorMessage) + ConditionalErrorMessage(viewModel.errorMessage, "verifyPhoneCode") val singleCodes = viewModel.singleCodes.toList() diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt index e32fe3fdf..bed887156 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt @@ -141,7 +141,7 @@ private fun AuthenticatedScreen( ) val authErrorMessage by viewModel.errorMessage - ConditionalErrorMessage(authErrorMessage) + ConditionalErrorMessage(authErrorMessage, "authenticated") val isNotBusy by viewModel.isNotAuthenticating.collectAsStateWithLifecycle() diff --git a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt index 8f29f6c94..afc7a77c1 100644 --- a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt +++ b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt @@ -130,6 +130,7 @@ class AuthenticationViewModelTest { languageKey = EnglishLanguage.key, tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, + hideGettingStartedVideo = false, ), ) 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 4390b2605..e434c9766 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 @@ -209,17 +209,35 @@ class CasesViewModel @Inject constructor( val isIncidentLoading = incidentsRepository.isLoading - val showDataProgress = dataPullReporter.incidentDataPullStats.map { it.isOngoing } - val dataProgress = dataPullReporter.incidentDataPullStats.map { it.progress } + val dataProgress = combine( + dataPullReporter.incidentDataPullStats, + dataPullReporter.incidentSecondaryDataPullStats, + ::Pair, + ) + .map { (primary, secondary) -> + val showProgress = primary.isOngoing || secondary.isOngoing + val isSecondary = secondary.isOngoing + val progress = if (primary.isOngoing) primary.progress else secondary.progress + DataProgressMetrics( + isSecondary, + showProgress, + progress, + ) + } + .stateIn( + scope = viewModelScope, + initialValue = zeroDataProgress, + started = SharingStarted.WhileSubscribed(), + ) /** * Incident or worksites data are currently saving/caching/loading */ val isLoadingData = combine( isIncidentLoading, - showDataProgress, + dataProgress, worksitesRepository.isDeterminingWorksitesCount, - ) { b0, b1, b2 -> b0 || b1 || b2 } + ) { b0, progress, b2 -> b0 || progress.isLoadingPrimary || b2 } private var _mapCameraZoom = MutableStateFlow(MapViewCameraZoomDefault) val mapCameraZoom = _mapCameraZoom.asStateFlow() @@ -450,6 +468,12 @@ class CasesViewModel @Inject constructor( tableViewSort .onEach { qsm.tableViewSort.value = it } .launchIn(viewModelScope) + + dataPullReporter.onIncidentDataPullComplete + .onEach { + filterRepository.reapplyFilters() + } + .launchIn(viewModelScope) } fun syncWorksitesDelta() { @@ -774,3 +798,12 @@ data class WorksiteDistance( val worksite = data.worksite val claimStatus = data.claimStatus } + +data class DataProgressMetrics( + val isSecondaryData: Boolean = false, + val showProgress: Boolean = false, + val progress: Float = 0.0f, + val isLoadingPrimary: Boolean = showProgress && !isSecondaryData, +) + +val zeroDataProgress = DataProgressMetrics() 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 1682554d9..851aeecf0 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 @@ -62,6 +62,7 @@ import com.crisiscleanup.core.designsystem.component.actionRoundCornerShape import com.crisiscleanup.core.designsystem.component.actionSize import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme +import com.crisiscleanup.core.designsystem.theme.disabledAlpha import com.crisiscleanup.core.designsystem.theme.incidentDisasterContainerColor import com.crisiscleanup.core.designsystem.theme.incidentDisasterContentColor import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy @@ -81,8 +82,10 @@ import com.crisiscleanup.core.model.data.WorksiteMapMark import com.crisiscleanup.core.selectincident.SelectIncidentDialog import com.crisiscleanup.core.ui.LocalAppLayout import com.crisiscleanup.feature.cases.CasesViewModel +import com.crisiscleanup.feature.cases.DataProgressMetrics import com.crisiscleanup.feature.cases.R import com.crisiscleanup.feature.cases.model.WorksiteGoogleMapMark +import com.crisiscleanup.feature.cases.zeroDataProgress import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.Projection import com.google.android.gms.maps.model.CameraPosition @@ -170,8 +173,7 @@ internal fun CasesRoute( viewModel.onMapCameraChange(position, projection, activeChange) } } - val showDataProgress by viewModel.showDataProgress.collectAsStateWithLifecycle(false) - val dataProgress by viewModel.dataProgress.collectAsStateWithLifecycle(0f) + val dataProgressMetrics by viewModel.dataProgress.collectAsStateWithLifecycle() val onMapMarkerSelect = remember(viewModel) { { mark: WorksiteMapMark -> viewCase(viewModel.incidentId, mark.id) } } @@ -185,8 +187,7 @@ internal fun CasesRoute( val isMyLocationEnabled = viewModel.isMyLocationEnabled val hasIncidents = (incidentsData as IncidentsData.Incidents).incidents.isNotEmpty() CasesScreen( - showDataProgress = showDataProgress, - dataProgress = dataProgress, + dataProgress = dataProgressMetrics, disasterResId = disasterResId, onSelectIncident = onIncidentSelect, onCasesAction = rememberOnCasesAction, @@ -333,8 +334,7 @@ internal fun NoCasesScreen( @Composable internal fun CasesScreen( - showDataProgress: Boolean = false, - dataProgress: Float = 0f, + dataProgress: DataProgressMetrics = zeroDataProgress, onSelectIncident: () -> Unit = {}, @DrawableRes disasterResId: Int = commonAssetsR.drawable.ic_disaster_other, onCasesAction: (CasesAction) -> Unit = {}, @@ -413,13 +413,17 @@ internal fun CasesScreen( modifier = Modifier .align(Alignment.TopCenter) .fillMaxWidth(), - visible = showDataProgress, + visible = dataProgress.showProgress, enter = fadeIn(), exit = fadeOut(), ) { + var progressColor = primaryOrangeColor + if (dataProgress.isSecondaryData) { + progressColor = progressColor.disabledAlpha() + } LinearProgressIndicator( - progress = dataProgress, - color = primaryOrangeColor, + progress = { dataProgress.progress }, + color = progressColor, ) } } diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index 6a227cec0..d66048e43 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -121,12 +121,13 @@ class MenuViewModel @Inject constructor( it.allowAllAnalytics } - val menuItemVisibility = appPreferencesRepository.userPreferences.map { - MenuItemVisibility( - showOnboarding = !it.shouldHideOnboarding, - showGettingStartedVideo = !it.hideGettingStartedVideo, - ) - } + val menuItemVisibility = appPreferencesRepository.userPreferences + .map { + MenuItemVisibility( + showOnboarding = !it.shouldHideOnboarding, + showGettingStartedVideo = !it.hideGettingStartedVideo, + ) + } .stateIn( scope = viewModelScope, initialValue = MenuItemVisibility(), @@ -168,8 +169,12 @@ class MenuViewModel @Inject constructor( } fun showGettingStartedVideo(show: Boolean) { + val hide = !show viewModelScope.launch(ioDispatcher) { - appPreferencesRepository.setHideGettingStartedVideo(!show) + appPreferencesRepository.setHideGettingStartedVideo(hide) + + // TODO Move to hide onboarding method when implemented + appPreferencesRepository.setShouldHideOnboarding(hide) } } } diff --git a/gradle.properties b/gradle.properties index 3b46899e1..622ef5b6b 100644 --- a/gradle.properties +++ b/gradle.properties @@ -7,7 +7,7 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. # Ensure important default jvmargs aren't overwritten. See https://github.com/gradle/gradle/issues/19750 -org.gradle.jvmargs=-Xmx6g -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g +org.gradle.jvmargs=-Xmx4096g -XX:MaxPermSize=4096m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -XX:+UseParallelGC -XX:MaxMetaspaceSize=1g # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7982173e3..2227ad001 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -accompanist = "0.31.2-alpha" +accompanist = "0.32.0" androidDesugarJdkLibs = "2.0.4" androidGradlePlugin = "8.2.0" androidMapsUtil = "2.3.0" @@ -7,22 +7,22 @@ androidMapsUtilKtx = "3.4.0" androidMaterial = "1.11.0" androidxActivity = "1.8.2" androidxAppCompat = "1.6.1" -androidxBrowser = "1.7.0" -androidxCamera = "1.3.1" -androidxComposeBom = "2023.10.01" +androidxBrowser = "1.8.0" +androidxCamera = "1.3.2" +androidxComposeBom = "2024.02.02" androidxComposeCompiler = "1.5.6" -androidxComposeMaterial3 = "1.2.0-beta01" +androidxComposeMaterial3 = "1.2.1" androidxComposeRuntimeTracing = "1.0.0-beta01" androidxConstraintLayout = "1.1.0-alpha13" androidxCore = "1.12.0" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.0.0" androidxEspresso = "3.5.1" -androidxHiltNavigationCompose = "1.1.0" -androidxLifecycle = "2.6.2" -androidxMacroBenchmark = "1.2.2" -androidxMetrics = "1.0.0-alpha04" -androidxNavigation = "2.7.6" +androidxHiltNavigationCompose = "1.2.0" +androidxLifecycle = "2.7.0" +androidxMacroBenchmark = "1.2.3" +androidxMetrics = "1.0.0-beta01" +androidxNavigation = "2.7.7" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxSecurityCrypto = "1.1.0-alpha06" @@ -31,45 +31,44 @@ androidxTestExt = "1.1.5" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxTracing = "1.2.0" -androidxUiAutomator = "2.2.0" +androidxUiAutomator = "2.3.0" androidxWindowManager = "1.2.0" androidxWork = "2.9.0" apacheCommonsText = "1.10.0" -coil = "2.3.0" -firebaseAppDistribution = "16.0.0-beta11" -firebaseBom = "32.7.0" +coil = "2.5.0" +firebaseBom = "32.7.4" firebaseCrashlyticsPlugin = "2.9.9" firebasePerfPlugin = "1.4.2" -gmsPlugin = "4.4.0" +gmsPlugin = "4.4.1" googleMapsCompose = "2.9.1" googlePlaces = "3.3.0" -hilt = "2.48" -hiltExt = "1.1.0" +hilt = "2.50" +hiltExt = "1.2.0" jacoco = "0.8.7" junit4 = "4.13.2" jwtDecode = "2.0.1" kotlin = "1.9.21" -kotlinxCoroutines = "1.7.1" -kotlinxCoroutinesPlayServices = "1.7.1" -kotlinxDatetime = "0.4.0" -kotlinxSerializationJson = "1.5.1" +kotlinxCoroutines = "1.7.3" +kotlinxCoroutinesPlayServices = "1.7.3" +kotlinxDatetime = "0.5.0" +kotlinxSerializationJson = "1.6.0" ksp = "1.9.21-1.0.15" -lint = "31.2.0" +lint = "31.3.0" mlkitBarcodeScanning = "17.2.0" mockk = "1.13.5" -okhttp = "4.11.0" +okhttp = "4.12.0" philJayRrule = "1.0.3" -playServicesLocation = "21.0.1" +playServicesLocation = "21.2.0" playServicesMaps = "18.2.0" -protobuf = "3.23.4" -protobufPlugin = "0.9.1" +protobuf = "3.24.4" +protobufPlugin = "0.9.4" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" room = "2.6.1" secrets = "2.0.1" squareSeismic = "1.0.3" timeAgo = "4.0.3" -turbine = "0.13.0" +turbine = "1.0.0" zxing = "3.5.2" [libraries] @@ -132,8 +131,6 @@ coil-kt = { group = "io.coil-kt", name = "coil", version.ref = "coil" } coil-kt-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } -firebase-appdistribution-api = { group = "com.google.firebase", name = "firebase-appdistribution-api-ktx", version.ref = "firebaseAppDistribution" } -firebase-appdistribution = { group = "com.google.firebase", name = "firebase-appdistribution", version.ref = "firebaseAppDistribution" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 1c49bf1af..865b57aa3 100644 --- a/secrets.defaults.properties +++ b/secrets.defaults.properties @@ -5,5 +5,6 @@ API_BASE_URL="http://10.0.2.2:5000" BASE_URL="http://localhost:8080" APP_LINK_TLD="localhost" MAPS_API_KEY="create and set key" +GETTING_STARTED_VIDEO_URL="https://www.youtube.com/watch?v=ot4LZjtK0xo" DEBUG_EMAIL_ADDRESS="fill@me.in" -DEBUG_ACCOUNT_PASSWORD="password" \ No newline at end of file +DEBUG_ACCOUNT_PASSWORD="password" diff --git a/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index 97ba477a0..d4ca9818f 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + +