From d9889d80c4b02fa82ce7a88c6bd187ef65aa11d8 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Mar 2024 11:07:10 -0400 Subject: [PATCH 1/8] Update dependencies --- gradle/libs.versions.toml | 55 ++++++++++++++++++--------------------- 1 file changed, 26 insertions(+), 29 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7982173e..2227ad00 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" } From 29ce0943685dcb9767322351fe4add5307d787f3 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Mar 2024 11:59:07 -0400 Subject: [PATCH 2/8] Add config property to default properties --- secrets.defaults.properties | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/secrets.defaults.properties b/secrets.defaults.properties index 1c49bf1a..865b57aa 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" From 1095a810fe585919601320d3415a46a7e5cff097 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Mar 2024 13:53:50 -0400 Subject: [PATCH 3/8] Lint and update inconsistencies --- .../core/data/repository/AppPreferencesRepositoryTest.kt | 1 + .../core/designsystem/icon/CrisisCleanupIcons.kt | 5 ++++- .../feature/authentication/ui/AuthComposables.kt | 3 ++- .../feature/authentication/AuthenticationViewModelTest.kt | 1 + sync/work/src/main/AndroidManifest.xml | 2 ++ 5 files changed, 10 insertions(+), 2 deletions(-) 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 fa9a3b7f..f642bde7 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/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 a4879a95..7219b9dc 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,6 +2,8 @@ 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 @@ -49,7 +51,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/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 c9ae7985..929b07e3 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 @@ -20,6 +20,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 @@ -92,7 +93,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/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt index 8f29f6c9..897f5864 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/sync/work/src/main/AndroidManifest.xml b/sync/work/src/main/AndroidManifest.xml index 97ba477a..d4ca9818 100644 --- a/sync/work/src/main/AndroidManifest.xml +++ b/sync/work/src/main/AndroidManifest.xml @@ -1,6 +1,8 @@ + + Date: Wed, 13 Mar 2024 17:32:54 -0400 Subject: [PATCH 4/8] Open to different main tab depending on hide onboarding setting --- app/build.gradle.kts | 2 +- .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 11 ++++++++++- .../crisiscleanup/ui/CrisisCleanupAppState.kt | 8 ++++++-- .../feature/menu/MenuViewModel.kt | 19 ++++++++++++------- gradle.properties | 2 +- 5 files changed, 30 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d6eaada..ac2008fc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 190 + val buildVersion = 191 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 9e150c64..837e4aea 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 d6a49cdf..e05c7965 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/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index 6a227cec..d66048e4 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 3b46899e..622ef5b6 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 From 5452513a906d802c4fc71f326d76acc82ec841cb Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Mar 2024 18:19:00 -0400 Subject: [PATCH 5/8] Add network flags and form data paging endpoint --- .../model/NetworkFlagsFormDataResult.kt | 29 + .../core/network/retrofit/DataApiClient.kt | 9 + .../network/model/NetworkFlagsFormDataTest.kt | 103 ++ .../resources/getFlagsFormDataSuccess.json | 1501 +++++++++++++++++ 4 files changed, 1642 insertions(+) create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataResult.kt create mode 100644 core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkFlagsFormDataTest.kt create mode 100644 core/network/src/test/resources/getFlagsFormDataSuccess.json 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 00000000..16bd172f --- /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 0354902f..712868c8 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,7 @@ 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.NetworkFlagsFormDataResult import com.crisiscleanup.core.network.model.NetworkIncidentResult import com.crisiscleanup.core.network.model.NetworkIncidentsResult import com.crisiscleanup.core.network.model.NetworkLanguageTranslationResult @@ -233,6 +234,14 @@ private interface DataSourceApi { @TokenAuthenticationHeader @GET("incident_requests") suspend fun getRedeployRequests(): NetworkRedeployRequestsResult + + @TokenAuthenticationHeader + @GET("worksites_data_flags") + suspend fun getWorksitesFlagsFormData( + @Query("incident") incidentId: Long, + @Query("offset") offset: Long, + @Query("limit") limit: Long, + ): NetworkFlagsFormDataResult } private val worksiteCoreDataFields = listOf( 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 00000000..c9044d82 --- /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) + } +} \ No newline at end of file diff --git a/core/network/src/test/resources/getFlagsFormDataSuccess.json b/core/network/src/test/resources/getFlagsFormDataSuccess.json new file mode 100644 index 00000000..0d72e889 --- /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 From 12215964cf266f39d1d99d501b39c4891f1f0f75 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 15 Mar 2024 15:18:32 -0400 Subject: [PATCH 6/8] Cache worksites form data for large incidents --- .../core/data/IncidentWorksitesDataCache.kt | 111 +- .../core/data/IncidentWorksitesFullSyncer.kt | 41 +- .../IncidentWorksitesSecondaryDataSyncer.kt | 244 ++ .../core/data/IncidentWorksitesSyncer.kt | 14 +- .../core/data/SyncCacheDeviceInspector.kt | 18 + .../crisiscleanup/core/data/di/DataModule.kt | 18 +- .../model/IncidentWorksitesPageRequest.kt | 32 +- .../OfflineFirstWorksitesRepository.kt | 1 + .../data/util/IncidentDataPullReporter.kt | 1 + .../40.json | 2810 +++++++++++++++++ .../core/database/CrisisCleanupDatabase.kt | 5 +- .../core/database/dao/WorksiteDaoPlus.kt | 27 + .../core/database/dao/WorksiteSyncStatDao.kt | 41 + .../model/PopulatedIncidentSyncStats.kt | 5 + .../database/model/WorksiteSyncStatsEntity.kt | 64 + .../designsystem/icon/CrisisCleanupIcons.kt | 1 - .../core/model/data/IncidentDataSyncStats.kt | 2 +- .../network/CrisisCleanupNetworkDataSource.kt | 8 + .../core/network/retrofit/DataApiClient.kt | 22 +- .../network/model/NetworkFlagsFormDataTest.kt | 2 +- .../authentication/ui/AuthComposables.kt | 2 - .../AuthenticationViewModelTest.kt | 2 +- .../feature/cases/CasesViewModel.kt | 35 +- .../feature/cases/ui/CasesScreen.kt | 22 +- 24 files changed, 3460 insertions(+), 68 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesSecondaryDataSyncer.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/SyncCacheDeviceInspector.kt create mode 100644 core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/40.json 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 d8837958..49139786 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 5eceb45c..b7737e56 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,6 +13,7 @@ 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 @@ -35,7 +36,7 @@ data class IncidentProgressPullStats( interface WorksitesFullSyncer { val fullPullStats: Flow - + val secondaryDataPullStats: Flow suspend fun sync(incidentId: Long) } @@ -48,11 +49,13 @@ 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 suspend fun sync(incidentId: Long) { worksiteSyncStatDao.getIncidentSyncStats(incidentId)?.let { syncStats -> @@ -65,28 +68,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 +138,12 @@ class IncidentWorksitesFullSyncer @Inject constructor( } if (pagedCount >= worksiteCount) { worksiteSyncStatDao.upsert( - syncStats.copy(syncedAt = syncStartedAt), + fullStats.copy(syncedAt = syncStartedAt), ) } } else { + secondaryDataSyncer.sync(incidentId, syncStats) + if (locationQueryParameters.hasLocation) { val isSynced = saveWorksitesAroundLocation( incidentId, @@ -149,7 +154,7 @@ class IncidentWorksitesFullSyncer @Inject constructor( } if (isSynced) { worksiteSyncStatDao.upsert( - syncStats.copy( + fullStats.copy( syncedAt = syncStartedAt, latitude = locationQueryParameters.latitude, longitude = locationQueryParameters.longitude, 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 00000000..f5c441ac --- /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 730e35f9..3a4d819b 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 00000000..ea59b2a9 --- /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 7f51d9e5..985d8476 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 174e8fcb..91b4e4f0 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/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index 7ab8a3d0..fb05ac01 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,7 @@ class OfflineFirstWorksitesRepository @Inject constructor( override val syncWorksitesFullIncidentId = MutableStateFlow(EmptyIncident.id) override val incidentDataPullStats = worksitesSyncer.dataPullStats + override val incidentSecondaryDataPullStats = worksitesFullSyncer.secondaryDataPullStats 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 e2d358bc..157d9a24 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,7 @@ import kotlin.time.Duration.Companion.seconds interface IncidentDataPullReporter { val incidentDataPullStats: Flow + val incidentSecondaryDataPullStats: Flow } data class IncidentDataPullStats( 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 00000000..ef2e0b42 --- /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 a6d2a1c7..e8bb4b44 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 65316f5f..e6b734da 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 bbc6527e..b814b105 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 23e108bc..8fdbbf68 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 bfde7104..3a57806f 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 7219b9dc..112c1de7 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 @@ -6,7 +6,6 @@ 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 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 fc5660bc..21fb5611 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 06e3b7ca..3edaaf17 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/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 712868c8..fbcea248 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,7 @@ 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 @@ -239,8 +240,9 @@ private interface DataSourceApi { @GET("worksites_data_flags") suspend fun getWorksitesFlagsFormData( @Query("incident") incidentId: Long, - @Query("offset") offset: Long, - @Query("limit") limit: Long, + @Query("limit") limit: Int, + @Query("offset") offset: Int?, + @Query("updated_at__gt") updatedAtAfter: Instant?, ): NetworkFlagsFormDataResult } @@ -362,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 index c9044d82..93bcd7b1 100644 --- 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 @@ -100,4 +100,4 @@ class NetworkFlagsFormDataTest { ) assertEquals(expected, entry) } -} \ 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 929b07e3..95d57dfd 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 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 897f5864..afc7a77c 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,7 +130,7 @@ class AuthenticationViewModelTest { languageKey = EnglishLanguage.key, tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, - hideGettingStartedVideo = 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 4390b260..f2f02f56 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() @@ -774,3 +792,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 1682554d..851aeecf 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, ) } } From dc2fffbeea49d399868077e07a03abfa9064d0fa Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 15 Mar 2024 16:16:37 -0400 Subject: [PATCH 7/8] Reapply filters after additional data is cached from the network --- .../core/data/IncidentWorksitesFullSyncer.kt | 7 +++++++ .../data/repository/CasesFilterRepository.kt | 17 ++++++++++++++--- .../OfflineFirstWorksitesRepository.kt | 1 + .../core/data/util/IncidentDataPullReporter.kt | 1 + .../feature/cases/CasesViewModel.kt | 6 ++++++ 5 files changed, 29 insertions(+), 3 deletions(-) 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 b7737e56..5a5d1f57 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 @@ -19,6 +19,7 @@ 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 @@ -37,6 +38,7 @@ data class IncidentProgressPullStats( interface WorksitesFullSyncer { val fullPullStats: Flow val secondaryDataPullStats: Flow + val onFullDataPullComplete: Flow suspend fun sync(incidentId: Long) } @@ -56,6 +58,7 @@ class IncidentWorksitesFullSyncer @Inject constructor( ) : 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 -> @@ -140,6 +143,8 @@ class IncidentWorksitesFullSyncer @Inject constructor( worksiteSyncStatDao.upsert( fullStats.copy(syncedAt = syncStartedAt), ) + + onFullDataPullComplete.emit(incidentId) } } else { secondaryDataSyncer.sync(incidentId, syncStats) @@ -165,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/repository/CasesFilterRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CasesFilterRepository.kt index fccfcbb3..05fdbe7f 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 fb05ac01..12061b20 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 @@ -83,6 +83,7 @@ class OfflineFirstWorksitesRepository @Inject constructor( 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 157d9a24..e90ea8aa 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 @@ -11,6 +11,7 @@ import kotlin.time.Duration.Companion.seconds interface IncidentDataPullReporter { val incidentDataPullStats: Flow val incidentSecondaryDataPullStats: Flow + val onIncidentDataPullComplete: Flow } data class IncidentDataPullStats( 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 f2f02f56..e434c976 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 @@ -468,6 +468,12 @@ class CasesViewModel @Inject constructor( tableViewSort .onEach { qsm.tableViewSort.value = it } .launchIn(viewModelScope) + + dataPullReporter.onIncidentDataPullComplete + .onEach { + filterRepository.reapplyFilters() + } + .launchIn(viewModelScope) } fun syncWorksitesDelta() { From 0ef458d68b739b7676ca1104bbd1097d712a6241 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 18 Mar 2024 12:52:51 -0400 Subject: [PATCH 8/8] Update error message test tag --- app/build.gradle.kts | 2 +- .../feature/authentication/ui/AuthComposables.kt | 7 +++++-- .../feature/authentication/ui/LoginWithEmailScreen.kt | 2 +- .../feature/authentication/ui/LoginWithPhoneScreen.kt | 4 ++-- .../feature/authentication/ui/RootAuthScreen.kt | 2 +- 5 files changed, 10 insertions(+), 7 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ac2008fc..4807eaad 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 191 + val buildVersion = 192 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" 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 95d57dfd..975c33ab 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 @@ -28,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, 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 e5eacaff..169dc13a 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 48c3b16f..a51de5cd 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 e32fe3fd..bed88715 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()