From d3207d86b77bd38bb3caf5810138a8d03de6b345 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 13:28:41 -0400 Subject: [PATCH 01/23] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0792389e..75664808 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 277 + val buildVersion = 278 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" From dd37b1c1ff62b46b1d7445a77e6b9b0fb94d1c32 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 16:06:29 -0400 Subject: [PATCH 02/23] Persist map satellite setting --- .../core/data/repository/AppPreferencesRepository.kt | 4 ++++ .../core/data/repository/LocalAppPreferencesRepository.kt | 2 ++ .../com/crisiscleanup/core/data/user_preferences.proto | 2 ++ .../core/datastore/LocalAppPreferencesDataSource.kt | 8 ++++++++ .../java/com/crisiscleanup/core/model/data/UserData.kt | 2 ++ .../crisiscleanup/feature/caseeditor/ui/LocationScreen.kt | 5 ++--- .../feature/caseeditor/ui/MoveLocationOnMapScreen.kt | 5 ++--- .../feature/caseeditor/ui/PropertyLocationViews.kt | 6 ++++-- .../crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt | 5 ++--- .../com/crisiscleanup/feature/cases/CasesViewModel.kt | 8 ++++++++ .../com/crisiscleanup/feature/cases/ui/CasesScreen.kt | 6 ++---- .../incidentcache/ui/IncidentWorksitesCacheScreen.kt | 5 ++--- 12 files changed, 40 insertions(+), 18 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt index d4377885..f3265c1b 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt @@ -85,4 +85,8 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setSyncMediaImmediate(syncImmediate: Boolean) { preferencesDataSource.saveSyncMediaImmediate(syncImmediate) } + + override suspend fun setMapSatelliteView(isSatellite: Boolean) { + preferencesDataSource.saveMapSatelliteView(isSatellite) + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt index f7ca4222..7206f71d 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt @@ -42,4 +42,6 @@ interface LocalAppPreferencesRepository { suspend fun setWorkScreenView(isTableView: Boolean) suspend fun setSyncMediaImmediate(syncImmediate: Boolean) + + suspend fun setMapSatelliteView(isSatellite: Boolean) } diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index e0580b4e..4f375bf4 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -43,4 +43,6 @@ message UserPreferences { bool is_work_screen_table_view = 15; bool sync_media_immediate = 16; + + bool is_map_satellite_view = 17; } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index 9a167752..1b74ebca 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -54,6 +54,8 @@ class LocalAppPreferencesDataSource @Inject constructor( isWorkScreenTableView = it.isWorkScreenTableView, isSyncMediaImmediate = it.syncMediaImmediate, + + isMapSatelliteView = it.isMapSatelliteView, ) } @@ -163,6 +165,12 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { syncMediaImmediate = syncImmediate } } } + + suspend fun saveMapSatelliteView(isSatellite: Boolean) { + userPreferences.updateData { + it.copy { isMapSatelliteView = isSatellite } + } + } } private fun IncidentMapBoundsProto.asExternalModel() = IncidentCoordinateBounds( diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index 37e82643..2ae18b82 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -27,4 +27,6 @@ data class UserData( val isWorkScreenTableView: Boolean, val isSyncMediaImmediate: Boolean, + + val isMapSatelliteView: Boolean, ) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt index 01d27186..32d14e3d 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt @@ -51,7 +51,7 @@ import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState @Composable private fun AddressSummaryInColumn( @@ -138,9 +138,8 @@ internal fun BoxScope.LocationMapView( disablePanning = disablePanning, ) - val markerState = rememberMarkerState() val coordinates by editor.locationInputData.coordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val mapMarkerIcon by editor.mapMarkerIcon.collectAsStateWithLifecycle() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt index bd0793a3..1a44f19d 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt @@ -49,7 +49,7 @@ import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState @Composable internal fun EditCaseMapMoveLocationRoute( @@ -253,9 +253,8 @@ private fun BoxScope.MoveMapUnderLocation( val mapCameraZoom by editor.mapCameraZoom.collectAsStateWithLifecycle() - val markerState = rememberMarkerState() val coordinates by editor.locationInputData.coordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val mapMarkerIcon by editor.mapMarkerIcon.collectAsStateWithLifecycle() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt index d4cb9ced..f5e22103 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt @@ -1,9 +1,9 @@ package com.crisiscleanup.feature.caseeditor.ui -import android.content.res.Configuration import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn @@ -13,6 +13,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.coerceAtMost @@ -92,7 +93,7 @@ internal fun PropertyLocationView( ) } - val screenHeight = Configuration.SCREEN_HEIGHT_DP_UNDEFINED.dp + val screenHeight = LocalWindowInfo.current.containerSize.height.dp val mapHeight = screenHeight.times(0.5f).coerceAtMost(240.dp) val mapModifier = Modifier.sizeIn(maxHeight = mapHeight) val cameraPositionState = rememberCameraPositionState() @@ -100,6 +101,7 @@ internal fun PropertyLocationView( LocationMapView( viewModel, editor, + Modifier.fillMaxSize(), zoomControls = true, disablePanning = true, cameraPositionState = cameraPositionState, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt index 886431dd..b46d93c8 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt @@ -115,7 +115,7 @@ import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch // TODO Use/move common dimensions @@ -1098,8 +1098,7 @@ private fun PropertyInfoMapView( disablePanning = true, ) - val markerState = rememberMarkerState() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) val update = CameraUpdateFactory.newLatLngZoom(coordinates, 13f) cameraPositionState.move(update) 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 5dccc591..58d54ce6 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt @@ -155,6 +155,8 @@ class CasesViewModel @Inject constructor( val filtersCount = filterRepository.filtersCount + val isMapSatelliteView = appPreferencesRepository.userPreferences.map { it.isMapSatelliteView } + val isTableView = qsm.isTableView private val tableDataDistanceSortSearchRadius = 100.0f @@ -641,6 +643,12 @@ class CasesViewModel @Inject constructor( fun zoomToInteractive() = adjustMapZoom(MAP_MARKERS_ZOOM_LEVEL + 0.5f) + fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setMapSatelliteView(isSatellite) + } + } + private fun setMapToMyCoordinates() { viewModelScope.launch { locationProvider.getLocation(10.seconds)?.let { myLocation -> diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt index 2bedb74a..b2e5432d 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreen.kt @@ -400,7 +400,7 @@ internal fun CasesScreen( enableIncidentSelect = enableIncidentSelect, ) } else { - var isSatelliteMapType by remember { mutableStateOf(false) } + val isSatelliteMapType by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) CasesMapView( mapCameraBounds, @@ -426,9 +426,7 @@ internal fun CasesScreen( onCasesAction(CasesAction.Layers) }, isSatelliteMapType = isSatelliteMapType, - onToggleSatelliteType = { isSatellite: Boolean -> - isSatelliteMapType = isSatellite - }, + onToggleSatelliteType = viewModel::setMapSatelliteView, ) } CasesOverlayElements( diff --git a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt index e5085207..b5e25d70 100644 --- a/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt +++ b/feature/incidentcache/src/main/kotlin/com/crisiscleanup/feature/incidentcache/ui/IncidentWorksitesCacheScreen.kt @@ -68,7 +68,7 @@ import com.google.maps.android.compose.Circle import com.google.maps.android.compose.GoogleMap import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState -import com.google.maps.android.compose.rememberMarkerState +import com.google.maps.android.compose.rememberUpdatedMarkerState import kotlinx.coroutines.launch @Composable @@ -463,9 +463,8 @@ private fun MovableMapView( val mapCameraZoom by editor.mapCameraZoom.collectAsStateWithLifecycle() - val markerState = rememberMarkerState() val coordinates by editor.centerCoordinates.collectAsStateWithLifecycle() - markerState.position = coordinates + val markerState = rememberUpdatedMarkerState(coordinates) var uiSettings by rememberMapUiSettings() LaunchedEffect(isEditable, isMovable) { From 8f0914159676e74a6b7efe297d3ac3ac95711a9e Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 16:58:16 -0400 Subject: [PATCH 03/23] Toggle map between normal and satellite across screens --- .../component/MapViewToggleButton.kt | 44 ++++++++++++ .../caseeditor/CreateEditCaseViewModel.kt | 12 ++++ .../caseeditor/EditCaseBaseViewModel.kt | 5 ++ .../caseeditor/EditCaseLocationViewModel.kt | 14 +++- .../caseeditor/EditCaseNotesFlagsViewModel.kt | 21 ------ .../feature/caseeditor/ViewCaseViewModel.kt | 10 +++ .../feature/caseeditor/ui/LocationScreen.kt | 9 ++- .../caseeditor/ui/MoveLocationOnMapScreen.kt | 71 +++++++++++++++---- .../caseeditor/ui/PropertyLocationViews.kt | 9 +++ .../feature/caseeditor/ui/ViewCaseScreen.kt | 39 +++++++--- .../feature/cases/ui/CasesScreen.kt | 9 +-- 11 files changed, 192 insertions(+), 51 deletions(-) create mode 100644 core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt new file mode 100644 index 00000000..27495633 --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/MapViewToggleButton.kt @@ -0,0 +1,44 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.disabledAlpha + +@Composable +fun BoxScope.MapViewToggleButton( + isSatelliteView: Boolean, + onToggle: (Boolean) -> Unit, + // TODO Common dimensions + padding: Dp = 8.dp, +) { + val mapViewIcon = if (isSatelliteView) { + CrisisCleanupIcons.NormalMap + } else { + CrisisCleanupIcons.SatelliteMap + } + val actionDescription = + LocalAppTranslator.current("~~Toggle map normal/satellite view") + CrisisCleanupIconButton( + Modifier + .padding(padding) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.disabledAlpha()) + .align(Alignment.TopEnd), + imageVector = mapViewIcon, + contentDescription = actionDescription, + onClick = { + onToggle(!isSatelliteView) + }, + ) +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 6b1a0bd6..0983bb20 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -27,6 +27,7 @@ import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -68,6 +69,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach @@ -96,6 +98,7 @@ class CreateEditCaseViewModel @Inject constructor( private val worksiteChangeRepository: WorksiteChangeRepository, localImageRepository: LocalImageRepository, private val worksiteImageRepository: WorksiteImageRepository, + private val preferencesRepository: AppPreferencesRepository, private val syncPusher: SyncPusher, networkMonitor: NetworkMonitor, packageManager: PackageManager, @@ -137,6 +140,9 @@ class CreateEditCaseViewModel @Inject constructor( val showInvalidWorksiteSave = MutableStateFlow(false) val invalidWorksiteInfo = mutableStateOf(InvalidWorksiteInfo()) + override val isMapSatelliteView = + preferencesRepository.userPreferences.map { it.isMapSatelliteView } + private val editingWorksite = editableWorksiteProvider.editableWorksite val photosWorksiteId: Long get() = editingWorksite.value.id @@ -453,6 +459,12 @@ class CreateEditCaseViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + override fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + fun scheduleSync() { val worksite = editingWorksite.value if (!(worksite.isNew || isSyncing.value)) { diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt index 3290f69b..8e35ac3f 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseBaseViewModel.kt @@ -5,6 +5,7 @@ import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger +import kotlinx.coroutines.flow.Flow abstract class EditCaseBaseViewModel( protected val worksiteProvider: EditableWorksiteProvider, @@ -14,6 +15,10 @@ abstract class EditCaseBaseViewModel( val breakGlassHint = translator("actions.edit") val helpHint = translator("actions.help_alt") + abstract val isMapSatelliteView: Flow + + abstract fun setMapSatelliteView(isSatellite: Boolean) + abstract fun onSystemBack(): Boolean abstract fun onNavigateBack(): Boolean diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt index 5ba35f07..ce138eb0 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt @@ -19,6 +19,7 @@ import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.commoncase.model.CaseSummaryResult import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.SearchWorksitesRepository import com.crisiscleanup.core.mapmarker.DrawableResourceBitmapProvider import com.crisiscleanup.core.mapmarker.IncidentBoundsProvider @@ -617,6 +618,7 @@ internal class EditableLocationDataEditor( @HiltViewModel class EditCaseLocationViewModel @Inject constructor( worksiteProvider: EditableWorksiteProvider, + private val preferencesRepository: AppPreferencesRepository, permissionManager: PermissionManager, locationProvider: LocationProvider, boundsProvider: IncidentBoundsProvider, @@ -629,8 +631,12 @@ class EditCaseLocationViewModel @Inject constructor( translator: KeyResourceTranslator, @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, @Dispatcher(Default) coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default, - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : EditCaseBaseViewModel(worksiteProvider, translator, logger) { + + override val isMapSatelliteView = + preferencesRepository.userPreferences.map { it.isMapSatelliteView } + val editor: CaseLocationDataEditor = EditableLocationDataEditor( worksiteProvider, permissionManager, @@ -655,6 +661,12 @@ class EditCaseLocationViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + override fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + private fun onBackValidateSaveWorksite() = editor.onBackValidateSaveWorksite() override fun onSystemBack() = onBackValidateSaveWorksite() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt index ffffa9a7..4c15a8c0 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseNotesFlagsViewModel.kt @@ -1,12 +1,6 @@ package com.crisiscleanup.feature.caseeditor -import com.crisiscleanup.core.common.KeyResourceTranslator -import com.crisiscleanup.core.common.log.AppLogger -import com.crisiscleanup.core.common.log.CrisisCleanupLoggers -import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.feature.caseeditor.model.NotesFlagsInputData -import dagger.hilt.android.lifecycle.HiltViewModel -import javax.inject.Inject interface CaseNotesFlagsDataEditor { val notesFlagsInputData: NotesFlagsInputData @@ -34,18 +28,3 @@ internal class EditableNotesFlagsDataEditor( return true } } - -@HiltViewModel -class EditCaseNotesFlagsViewModel @Inject constructor( - worksiteProvider: EditableWorksiteProvider, - translator: KeyResourceTranslator, - @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, -) : EditCaseBaseViewModel(worksiteProvider, translator, logger) { - val editor: CaseNotesFlagsDataEditor = EditableNotesFlagsDataEditor(worksiteProvider) - - private fun validateSaveWorksite() = editor.validateSaveWorksite() - - override fun onSystemBack() = validateSaveWorksite() - - override fun onNavigateBack() = validateSaveWorksite() -} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index a62da68f..8a9bd254 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -30,6 +30,7 @@ import com.crisiscleanup.core.commoncase.WorkTypeTransferType import com.crisiscleanup.core.commoncase.oneDecimalFormat import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -92,6 +93,7 @@ class ViewCaseViewModel @Inject constructor( languageRefresher: LanguageRefresher, workTypeStatusRepository: WorkTypeStatusRepository, localImageRepository: LocalImageRepository, + private val preferencesRepository: AppPreferencesRepository, private val editableWorksiteProvider: EditableWorksiteProvider, val transferWorkTypeProvider: TransferWorkTypeProvider, permissionManager: PermissionManager, @@ -122,6 +124,8 @@ class ViewCaseViewModel @Inject constructor( private var inBoundsPinIcon: BitmapDescriptor? = null private var outOfBoundsPinIcon: BitmapDescriptor? = null + val isMapSatelliteView = preferencesRepository.userPreferences.map { it.isMapSatelliteView } + val isSyncing = combine( worksiteChangeRepository.syncingWorksiteIds, localImageRepository.syncingWorksiteId, @@ -644,6 +648,12 @@ class ViewCaseViewModel @Inject constructor( } } + fun setMapSatelliteView(isSatellite: Boolean) { + viewModelScope.launch(ioDispatcher) { + preferencesRepository.setMapSatelliteView(isSatellite) + } + } + fun jumpToCaseOnMap() { caseData.value?.let { val coordinates = it.worksite.coordinates diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt index 32d14e3d..333a8d19 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocationScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind @@ -49,6 +50,7 @@ import com.google.android.gms.maps.model.CameraPosition import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState @@ -115,6 +117,7 @@ private fun AddressSummaryInColumn( internal fun BoxScope.LocationMapView( viewModel: EditCaseBaseViewModel, editor: CaseLocationDataEditor, + isSatelliteView: Boolean, modifier: Modifier = Modifier, zoomControls: Boolean = false, disablePanning: Boolean = false, @@ -143,7 +146,11 @@ internal fun BoxScope.LocationMapView( val mapMarkerIcon by editor.mapMarkerIcon.collectAsStateWithLifecycle() - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } GoogleMap( modifier = modifier.testTag("mapView"), uiSettings = uiSettings, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt index 1a44f19d..27ee0fd2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt @@ -3,6 +3,7 @@ package com.crisiscleanup.feature.caseeditor.ui import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -29,6 +30,7 @@ import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.ExplainLocationPermissionDialog import com.crisiscleanup.core.designsystem.component.LIST_DETAIL_DETAIL_WEIGHT import com.crisiscleanup.core.designsystem.component.LIST_DETAIL_LIST_WEIGHT +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction import com.crisiscleanup.core.designsystem.component.cancelButtonColors import com.crisiscleanup.core.designsystem.component.listDetailDetailMaxWidth @@ -47,6 +49,7 @@ import com.google.android.gms.maps.Projection import com.google.android.gms.maps.model.CameraPosition import com.google.maps.android.compose.CameraMoveStartedReason import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState @@ -72,6 +75,8 @@ internal fun EditCaseMapMoveLocationRoute( val isOnline by viewModel.isOnline.collectAsStateWithLifecycle() + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) + val isListDetailLayout = LocalDimensions.current.isListDetailWidth val t = LocalAppTranslator.current @@ -86,20 +91,24 @@ internal fun EditCaseMapMoveLocationRoute( title, locationQuery, editor, - isOnline, + isOnline = isOnline, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, onBack, - isEditable, onUseMyLocation, + viewModel::setMapSatelliteView, ) } else { PortraitLayout( title, locationQuery, editor, - isOnline, + isOnline = isOnline, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, onBack, - isEditable, onUseMyLocation, + viewModel::setMapSatelliteView, ) } @@ -139,6 +148,27 @@ private fun UseMyLocationAction( } } +@Composable +private fun ColumnScope.MoveMapContainer( + editor: CaseLocationDataEditor, + isEditable: Boolean, + isMapSatelliteView: Boolean, + setMapSatelliteView: (Boolean) -> Unit, +) { + Box(Modifier.weight(1f)) { + MoveMapUnderLocation( + editor, + isEditable = isEditable, + isSatelliteView = isMapSatelliteView, + ) + + MapViewToggleButton( + isMapSatelliteView, + setMapSatelliteView, + ) + } +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun PortraitLayout( @@ -146,9 +176,11 @@ private fun PortraitLayout( locationQuery: String, editor: CaseLocationDataEditor, isOnline: Boolean, + isEditable: Boolean, + isMapSatelliteView: Boolean, onBack: () -> Unit = {}, - isEditable: Boolean = false, onUseMyLocation: () -> Unit = {}, + setMapSatelliteView: (Boolean) -> Unit = {}, ) { Column { TopAppBarBackAction( @@ -165,9 +197,12 @@ private fun PortraitLayout( } if (locationQuery.isBlank()) { - Box(Modifier.weight(1f)) { - MoveMapUnderLocation(editor, isEditable) - } + MoveMapContainer( + editor, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, + setMapSatelliteView, + ) UseMyLocationAction( isEditable = isEditable, @@ -189,9 +224,11 @@ private fun ListDetailLayout( locationQuery: String, editor: CaseLocationDataEditor, isOnline: Boolean, + isEditable: Boolean, + isMapSatelliteView: Boolean, onBack: () -> Unit = {}, - isEditable: Boolean = false, onUseMyLocation: () -> Unit = {}, + setMapSatelliteView: (Boolean) -> Unit = {}, ) { Row { Column(Modifier.weight(LIST_DETAIL_LIST_WEIGHT)) { @@ -224,9 +261,12 @@ private fun ListDetailLayout( .sizeIn(maxWidth = listDetailDetailMaxWidth), ) { if (locationQuery.isBlank()) { - Box(Modifier.weight(1f)) { - MoveMapUnderLocation(editor, isEditable) - } + MoveMapContainer( + editor, + isEditable = isEditable, + isMapSatelliteView = isMapSatelliteView, + setMapSatelliteView, + ) } else { editor.isMapLoaded = false AddressSearchResults(editor, locationQuery, isEditable = isEditable) @@ -239,6 +279,7 @@ private fun ListDetailLayout( private fun BoxScope.MoveMapUnderLocation( editor: CaseLocationDataEditor, isEditable: Boolean, + isSatelliteView: Boolean, modifier: Modifier = Modifier, ) { val onMapCameraChange = remember(editor) { @@ -265,7 +306,11 @@ private fun BoxScope.MoveMapUnderLocation( zoomGesturesEnabled = isEditable, ) } - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } val cameraPositionState = rememberCameraPositionState() GoogleMap( modifier = modifier, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt index f5e22103..19c4505a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.sizeIn import androidx.compose.material3.LocalTextStyle @@ -23,6 +24,7 @@ import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.ExplainLocationPermissionDialog import com.crisiscleanup.core.designsystem.component.HelpRow +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.OutlinedSingleLineTextField import com.crisiscleanup.core.designsystem.component.WithHelpDialog import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme @@ -93,6 +95,7 @@ internal fun PropertyLocationView( ) } + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) val screenHeight = LocalWindowInfo.current.containerSize.height.dp val mapHeight = screenHeight.times(0.5f).coerceAtMost(240.dp) val mapModifier = Modifier.sizeIn(maxHeight = mapHeight) @@ -101,11 +104,17 @@ internal fun PropertyLocationView( LocationMapView( viewModel, editor, + isMapSatelliteView, Modifier.fillMaxSize(), zoomControls = true, disablePanning = true, cameraPositionState = cameraPositionState, ) + + MapViewToggleButton( + isMapSatelliteView, + viewModel::setMapSatelliteView, + ) } LocationMapActionBar( diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt index b46d93c8..2c9028c7 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt @@ -77,6 +77,7 @@ import com.crisiscleanup.core.designsystem.component.LeadingIconChip import com.crisiscleanup.core.designsystem.component.LinkifyEmailText import com.crisiscleanup.core.designsystem.component.LinkifyLocationText import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText +import com.crisiscleanup.core.designsystem.component.MapViewToggleButton import com.crisiscleanup.core.designsystem.component.TemporaryDialog import com.crisiscleanup.core.designsystem.component.WorkTypeBusyAction import com.crisiscleanup.core.designsystem.component.WorkTypePrimaryAction @@ -113,6 +114,7 @@ import com.google.android.gms.maps.model.BitmapDescriptor import com.google.android.gms.maps.model.LatLng import com.google.maps.android.compose.CameraPositionState import com.google.maps.android.compose.GoogleMap +import com.google.maps.android.compose.MapType import com.google.maps.android.compose.Marker import com.google.maps.android.compose.rememberCameraPositionState import com.google.maps.android.compose.rememberUpdatedMarkerState @@ -570,10 +572,11 @@ internal fun PropertyInfoRow( @Composable private fun CaseInfoView( worksite: Worksite, - viewModel: ViewCaseViewModel = hiltViewModel(), copyToClipboard: (String?) -> Unit = {}, + viewModel: ViewCaseViewModel = hiltViewModel(), ) { val mapMarkerIcon by viewModel.mapMarkerIcon.collectAsStateWithLifecycle() + val isMapSatelliteView by viewModel.isMapSatelliteView.collectAsStateWithLifecycle(false) val workTypeProfile by viewModel.workTypeProfile.collectAsStateWithLifecycle() val removeFlag = remember(viewModel) { { flag: WorksiteFlag -> viewModel.removeFlag(flag) } } @@ -617,9 +620,11 @@ private fun CaseInfoView( flagItems(worksite, removeFlag) propertyInfoItems( worksite, + isMapSatelliteView, mapMarkerIcon, copyToClipboard, distanceAwayText, + viewModel::setMapSatelliteView, viewModel::jumpToCaseOnMap, ) workItems( @@ -712,9 +717,11 @@ private fun FlagChip( @OptIn(ExperimentalFoundationApi::class) private fun LazyListScope.propertyInfoItems( worksite: Worksite, + isMapSatelliteView: Boolean, mapMarkerIcon: BitmapDescriptor? = null, copyToClipboard: (String?) -> Unit = {}, distanceAwayText: String = "", + setMapSatelliteView: (Boolean) -> Unit = {}, onJumpToCaseOnMap: () -> Unit = {}, ) { itemInfoSectionHeader(0, "caseForm.property_information") @@ -815,15 +822,26 @@ private fun LazyListScope.propertyInfoItems( } } - PropertyInfoMapView( - worksite.coordinates, - // TODO Common dimensions + Box( Modifier - .testTag("editCasePropertyInfoMapView") + // TODO Common dimensions .height(192.dp) .padding(top = edgeSpacingHalf), - mapMarkerIcon = mapMarkerIcon, - ) + ) { + PropertyInfoMapView( + worksite.coordinates, + isMapSatelliteView, + Modifier + .fillMaxSize() + .testTag("editCasePropertyInfoMapView"), + mapMarkerIcon = mapMarkerIcon, + ) + + MapViewToggleButton( + isMapSatelliteView, + setMapSatelliteView, + ) + } } } } @@ -1088,6 +1106,7 @@ data class IconTextAction( @Composable private fun PropertyInfoMapView( coordinates: LatLng, + isSatelliteView: Boolean, modifier: Modifier = Modifier, mapMarkerIcon: BitmapDescriptor? = null, onMapLoaded: () -> Unit = {}, @@ -1103,7 +1122,11 @@ private fun PropertyInfoMapView( val update = CameraUpdateFactory.newLatLngZoom(coordinates, 13f) cameraPositionState.move(update) - val mapProperties by rememberMapProperties() + var mapProperties by rememberMapProperties() + LaunchedEffect(isSatelliteView) { + val mapType = if (isSatelliteView) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) + } GoogleMap( modifier = modifier, uiSettings = uiSettings, diff --git a/feature/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 b2e5432d..858707e5 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 @@ -503,13 +503,8 @@ internal fun BoxScope.CasesMapView( isMyLocation = isMyLocationEnabled, ) LaunchedEffect(isSatelliteMapType) { - mapProperties = mapProperties.copy( - mapType = if (isSatelliteMapType) { - MapType.SATELLITE - } else { - MapType.NORMAL - }, - ) + val mapType = if (isSatelliteMapType) MapType.SATELLITE else MapType.NORMAL + mapProperties = mapProperties.copy(mapType = mapType) } GoogleMap( modifier = Modifier From 0038748d5ed8712cc34cefd910cac1cece7f02fc Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 17:30:41 -0400 Subject: [PATCH 04/23] Update Places SDK deprecations and fix address bug --- .../addresssearch/GooglePlaceAddressSearchRepository.kt | 7 +++---- .../feature/caseeditor/WrongLocationFlagManager.kt | 9 +++------ .../caseeditor/ui/addflag/WrongLocationFlagView.kt | 6 +++++- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt index 79325b3c..3539d8ec 100644 --- a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt +++ b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt @@ -18,8 +18,8 @@ import com.google.android.libraries.places.api.Places import com.google.android.libraries.places.api.model.AutocompletePrediction import com.google.android.libraries.places.api.model.AutocompleteSessionToken import com.google.android.libraries.places.api.model.Place +import com.google.android.libraries.places.api.model.PlaceTypes import com.google.android.libraries.places.api.model.RectangularBounds -import com.google.android.libraries.places.api.model.TypeFilter import com.google.android.libraries.places.api.net.FetchPlaceRequest import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest import com.google.android.libraries.places.api.net.PlacesClient @@ -143,15 +143,14 @@ class GooglePlaceAddressSearchRepository @Inject constructor( } val hasBounds = southwest != null && northeast != null - val bounds = - if (hasBounds) RectangularBounds.newInstance(southwest!!, northeast!!) else null + val bounds = if (hasBounds) RectangularBounds.newInstance(southwest, northeast) else null val request = FindAutocompletePredictionsRequest.builder() // Call either setLocationBias() OR setLocationRestriction(). .setLocationBias(bounds) // .setLocationRestriction(bounds) .setOrigin(center) .setCountries(countryCodes) - .setTypesFilter(listOf(TypeFilter.ADDRESS.toString().lowercase())) + .setTypesFilter(listOf(PlaceTypes.ADDRESS)) .setQuery(query) .setSessionToken(getSessionToken()) .build() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt index 22ea1eb5..a4340a43 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/WrongLocationFlagManager.kt @@ -56,14 +56,11 @@ class WrongLocationFlagManager( started = SharingStarted.WhileSubscribed(), ) - val validCoordinates = wrongLocationCoordinatesParse.mapLatest { - it?.let { latLng -> + val validCoordinates = wrongLocationCoordinatesParse.mapLatest { parsed -> + parsed?.let { isVerifyingCoordinates.value = true try { - val results = addressSearchRepository.getAddress(latLng) - results?.let { address -> - return@mapLatest address - } + return@mapLatest addressSearchRepository.getAddress(parsed) } finally { isVerifyingCoordinates.value = false } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt index 93e986b7..b92ff5ac 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/WrongLocationFlagView.kt @@ -92,7 +92,11 @@ internal fun ColumnScope.WrongLocationFlagView( } } - val onSave = remember(viewModel) { { viewModel.updateLocation(validCoordinates) } } + val onSave = remember(viewModel, validCoordinates) { + { + viewModel.updateLocation(validCoordinates) + } + } AddFlagSaveActionBar( onSave = onSave, onCancel = onBack, From 283d3b0ce0a220dd8f75e0cc79411843bb1301f2 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 12 Sep 2025 20:18:53 -0400 Subject: [PATCH 05/23] Determine Incident boundary centroid correctly in random orientations --- app/build.gradle.kts | 2 +- .../com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 75664808..e9f4c05f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 278 + val buildVersion = 279 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt index fa06099b..da42ea1c 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/util/CoordinatesUtil.kt @@ -9,6 +9,7 @@ import com.google.android.gms.maps.model.LatLng import com.google.android.gms.maps.model.LatLngBounds import com.google.maps.android.PolyUtil import com.google.maps.android.SphericalUtil +import kotlin.math.abs import kotlin.math.sqrt fun Pair.toLatLng() = LatLng(first, second) @@ -109,7 +110,7 @@ internal fun Collection.toBounds(): IncidentBounds { val polyDelta = polyPoint.subtract(incidentCentroid) val polyDeltaNorm = polyDelta.normalizeOrSelf() if (polyDelta != polyDeltaNorm && - polyDeltaNorm.latitude * deltaNorm.latitude + polyDeltaNorm.longitude * deltaNorm.longitude > 0.9 + abs(polyDeltaNorm.latitude * deltaNorm.latitude + polyDeltaNorm.longitude * deltaNorm.longitude) > 0.9 ) { val distance = SphericalUtil.computeDistanceBetween(incidentCentroid, polyPoint) From ebd2db79eda83636c5740254ebbc68e2c8bf88ce Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 17 Sep 2025 15:15:58 -0400 Subject: [PATCH 06/23] Prevent double back navigation at top level navigation --- app/build.gradle.kts | 2 +- .../navigation/CrisisCleanupNavHost.kt | 53 ++++++++++++++----- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e9f4c05f..b016f159 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 279 + val buildVersion = 280 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 837438f2..e6e7d7a1 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -7,6 +7,9 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavController import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost +import com.crisiscleanup.core.appnav.RouteConstant.ACCOUNT_RESET_PASSWORD_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.ACCOUNT_TRANSFER_ORG_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.CASES_FILTER_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASES_GRAPH_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASES_SEARCH_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASE_ADD_FLAG_ROUTE @@ -16,7 +19,15 @@ import com.crisiscleanup.core.appnav.RouteConstant.CASE_EDITOR_SEARCH_ADDRESS_RO import com.crisiscleanup.core.appnav.RouteConstant.CASE_HISTORY_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.CASE_SHARE_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.INCIDENT_WORKSITES_CACHE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.INVITE_TEAMMATE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.LISTS_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.REQUEST_REDEPLOY_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.USER_FEEDBACK_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_CASE_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.VIEW_CASE_TRANSFER_WORK_TYPES_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_IMAGE_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_LIST_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_TEAM_ROUTE import com.crisiscleanup.core.appnav.RouteConstant.WORKSITE_IMAGES_ROUTE import com.crisiscleanup.core.appnav.navigateToExistingCase import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier @@ -161,6 +172,8 @@ fun CrisisCleanupNavHost( val searchCasesOnBack = rememberBackOnRoute(navController, onBack, CASES_SEARCH_ROUTE) + val filterCasesOnBack = rememberBackOnRoute(navController, onBack, CASES_FILTER_ROUTE) + val caseEditorOnBack = rememberBackStartingRoute(navController, onBack, CASE_EDITOR_ROUTE) val searchAddressOnBack = @@ -168,6 +181,8 @@ fun CrisisCleanupNavHost( val moveLocationOnBack = rememberBackOnRoute(navController, onBack, CASE_EDITOR_MAP_MOVE_LOCATION_ROUTE) + val viewCaseOnBack = rememberBackStartingRoute(navController, onBack, VIEW_CASE_ROUTE) + val transferOnBack = rememberBackStartingRoute(navController, onBack, VIEW_CASE_TRANSFER_WORK_TYPES_ROUTE) @@ -177,6 +192,9 @@ fun CrisisCleanupNavHost( val historyOnBack = rememberBackOnRoute(navController, onBack, CASE_HISTORY_ROUTE) + val viewTeamOnBack = rememberBackStartingRoute(navController, onBack, VIEW_TEAM_ROUTE) + + val viewSingleImageOnBack = rememberBackStartingRoute(navController, onBack, VIEW_IMAGE_ROUTE) val worksiteImagesOnBack = rememberBackStartingRoute(navController, onBack, WORKSITE_IMAGES_ROUTE) @@ -184,6 +202,15 @@ fun CrisisCleanupNavHost( rememberBackOnRoute(navController, onBack, INCIDENT_WORKSITES_CACHE_ROUTE) val openIncidentCache = navController::navigateToIncidentWorksitesCache + val viewListsOnBack = rememberBackOnRoute(navController, onBack, LISTS_ROUTE) + val viewListOnBack = rememberBackStartingRoute(navController, onBack, VIEW_LIST_ROUTE) + val inviteTeammatesOnBack = rememberBackOnRoute(navController, onBack, INVITE_TEAMMATE_ROUTE) + val requestRedeployOnBack = rememberBackOnRoute(navController, onBack, REQUEST_REDEPLOY_ROUTE) + val userFeedbackOnBack = rememberBackOnRoute(navController, onBack, USER_FEEDBACK_ROUTE) + + val resetPasswordOnBack = rememberBackOnRoute(navController, onBack, ACCOUNT_RESET_PASSWORD_ROUTE) + val requestAccessOnBack = rememberBackStartingRoute(navController, onBack, ACCOUNT_TRANSFER_ORG_ROUTE) + NavHost( navController = navController, startDestination = startDestination, @@ -193,11 +220,11 @@ fun CrisisCleanupNavHost( navController, nestedGraphs = { casesSearchScreen(searchCasesOnBack, viewCase) - casesFilterScreen(onBack) + casesFilterScreen(filterCasesOnBack) caseEditorScreen(navController, caseEditorOnBack) caseEditSearchAddressScreen(navController, searchAddressOnBack) caseEditMoveLocationOnMapScreen(moveLocationOnBack) - existingCaseScreen(navController, onBack) + existingCaseScreen(navController, viewCaseOnBack) existingCaseTransferWorkTypesScreen(transferOnBack) caseAddFlagScreen(addFlagOnBack, replaceRouteViewCase) caseShareScreen(shareOnBack) @@ -213,7 +240,7 @@ fun CrisisCleanupNavHost( dashboardScreen() teamsScreen( nestedGraphs = { - viewTeamScreen(onBack) + viewTeamScreen(viewTeamOnBack) }, openAuthentication = openAuthentication, openViewTeam = navController::navigateToViewTeam, @@ -229,14 +256,14 @@ fun CrisisCleanupNavHost( openSyncLogs = navController::navigateToSyncInsights, ) incidentWorksitesCache(incidentWorksitesCacheOnBack) - viewSingleImageScreen(onBack) + viewSingleImageScreen(viewSingleImageOnBack) viewWorksiteImagesScreen(worksiteImagesOnBack) - inviteTeammateScreen(onBack) - requestRedeployScreen(onBack) - userFeedbackScreen(onBack) - listsScreen(navController, onBack) + inviteTeammateScreen(inviteTeammatesOnBack) + requestRedeployScreen(requestRedeployOnBack) + userFeedbackScreen(userFeedbackOnBack) + listsScreen(navController, viewListsOnBack) viewListScreen( - onBack, + viewListOnBack, openList = openList, openWorksite = openViewCase, ) @@ -244,14 +271,14 @@ fun CrisisCleanupNavHost( resetPasswordScreen( isAuthenticated = true, - onBack = onBack, - closeResetPassword = onBack, + onBack = resetPasswordOnBack, + closeResetPassword = resetPasswordOnBack, ) requestAccessScreen( true, - onBack = onBack, - closeRequestAccess = onBack, + onBack = requestAccessOnBack, + closeRequestAccess = requestAccessOnBack, openAuth = {}, openForgotPassword = {}, ) From 4eeea16c9507fa422c51101f094194b3c8b7a982 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 17 Sep 2025 16:50:01 -0400 Subject: [PATCH 07/23] Change inactive duration for performing data clearance --- .../crisiscleanup/sync/workers/InactivityWorker.kt | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt index f238b514..3f98c144 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/InactivityWorker.kt @@ -5,6 +5,7 @@ import androidx.hilt.work.HiltWorker import androidx.work.CoroutineWorker import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkerParameters +import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -21,8 +22,7 @@ import kotlinx.datetime.Clock import java.util.concurrent.TimeUnit import kotlin.time.Duration.Companion.days -private val clearDuration = 180.days -private const val REPEAT_DURATION_DAYS = 15L +private const val REPEAT_DURATION_DAYS = 2L @HiltWorker internal class InactivityWorker @AssistedInject constructor( @@ -30,6 +30,7 @@ internal class InactivityWorker @AssistedInject constructor( @Assisted workerParams: WorkerParameters, appMetricsRepository: LocalAppMetricsRepository, private val appDataManagementRepository: AppDataManagementRepository, + private val appEnv: AppEnv, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : CoroutineWorker(appContext, workerParams) { @@ -38,6 +39,13 @@ internal class InactivityWorker @AssistedInject constructor( override suspend fun doWork(): Result = withContext(ioDispatcher) { val latestAppOpen = appMetrics.first().appOpen.date val delta = Clock.System.now().minus(latestAppOpen) + val clearDuration = if (appEnv.isProduction) { + 60.days + } else if (appEnv.isDebuggable) { + 3.days + } else { + 6.days + } when { delta in clearDuration..999.days -> { if (appDataManagementRepository.backgroundClearAppData(false)) { From 7bd5f4d59fddadc0e28d285f1da5b24c36f3cc36 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 18 Sep 2025 13:53:27 -0400 Subject: [PATCH 08/23] Change work view as necessary when centering Case on map --- .../com/crisiscleanup/feature/cases/CasesViewModel.kt | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) 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 58d54ce6..73cbbcda 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 @@ -216,7 +216,14 @@ class CasesViewModel @Inject constructor( } val editedWorksiteLocation: LatLng? - get() = worksiteLocationEditor.takeEditedLocation()?.let { LatLng(it.first, it.second) } + get() = worksiteLocationEditor.takeEditedLocation()?.let { + // TODO Separate side effect + if (isTableView.value) { + isTableView.value = false + } + + LatLng(it.first, it.second) + } val isIncidentLoading = incidentsRepository.isLoading From 3066eb21fe857fa06f24eea681ff4a6fc940159d Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 18 Sep 2025 16:51:33 -0400 Subject: [PATCH 09/23] Rebuild FTS tables by app version --- .../repository/AppDataManagementRepository.kt | 22 +++++++++++++--- .../core/database/dao/IncidentDao.kt | 4 --- .../database/dao/IncidentOrganizationDao.kt | 4 --- .../core/database/dao/WorksiteDao.kt | 4 --- .../core/database/dao/fts/IncidentFts.kt | 16 +----------- .../core/database/dao/fts/OrganizationFts.kt | 18 ++----------- .../core/database/dao/fts/WorksiteTextFts.kt | 17 +----------- .../core/data/app_maintenance.proto | 8 ++++++ .../datastore/AppMaintenanceDataSource.kt | 23 ++++++++++++++++ .../datastore/AppMaintenanceSerializer.kt | 25 ++++++++++++++++++ .../core/datastore/di/DataStoreModule.kt | 14 ++++++++++ .../datastore/AppMaintenanceSerializerTest.kt | 26 +++++++++++++++++++ .../core/model/data/AppMaintenanceData.kt | 5 ++++ .../core/testing/model/UserData.kt | 1 + 14 files changed, 125 insertions(+), 62 deletions(-) create mode 100644 core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto create mode 100644 core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt create mode 100644 core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt create mode 100644 core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt index 751fd887..b19e051c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppDataManagementRepository.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.database.dao.WorksiteDaoPlus import com.crisiscleanup.core.database.dao.fts.rebuildIncidentFts import com.crisiscleanup.core.database.dao.fts.rebuildOrganizationFts import com.crisiscleanup.core.database.dao.fts.rebuildWorksiteTextFts +import com.crisiscleanup.core.datastore.AppMaintenanceDataSource import com.crisiscleanup.core.model.data.CasesFilter import com.crisiscleanup.core.model.data.InitialIncidentWorksitesCachePreferences import kotlinx.coroutines.CoroutineDispatcher @@ -67,11 +68,14 @@ class CrisisCleanupDataManagementRepository @Inject constructor( private val workTypeStatusRepository: WorkTypeStatusRepository, private val casesFilterRepository: CasesFilterRepository, private val appMetricsRepository: LocalAppMetricsRepository, + private val maintenanceDataSource: AppMaintenanceDataSource, private val accountEventBus: AccountEventBus, @ApplicationScope private val externalScope: CoroutineScope, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : AppDataManagementRepository { + private val isRebuildingFts = AtomicBoolean() + override val clearingAppDataStep = MutableStateFlow(ClearAppDataStep.None) override val isAppDataCleared = clearingAppDataStep.map { it == ClearAppDataStep.Cleared } @@ -81,9 +85,21 @@ class CrisisCleanupDataManagementRepository @Inject constructor( private val isClearingAppData = AtomicBoolean() override suspend fun rebuildFts() { - incidentDaoPlus.rebuildIncidentFts() - organizationDaoPlus.rebuildOrganizationFts() - worksiteDaoPlus.rebuildWorksiteTextFts() + if (!isRebuildingFts.compareAndSet(false, true)) { + return + } + + try { + val rebuildVersion = maintenanceDataSource.maintenanceData.first().ftsRebuildVersion + if (rebuildVersion < 260) { + incidentDaoPlus.rebuildIncidentFts() + organizationDaoPlus.rebuildOrganizationFts() + worksiteDaoPlus.rebuildWorksiteTextFts() + maintenanceDataSource.setFtsRebuildVersion(260) + } + } finally { + isRebuildingFts.set(false) + } } override fun clearAppData() { diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index 52caa430..323400ac 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -99,10 +99,6 @@ interface IncidentDao { @Upsert suspend fun upsertFormFields(formFields: Collection) - @Transaction - @Query("SELECT name FROM incidents ORDER BY RANDOM() LIMIT 1") - fun getRandomIncidentName(): String? - @Transaction @Query("INSERT INTO incident_fts(incident_fts) VALUES ('rebuild')") fun rebuildIncidentFts() diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt index 7363a798..38dd96f1 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt @@ -70,10 +70,6 @@ interface IncidentOrganizationDao { @Query("SELECT primary_location, secondary_location FROM incident_organizations WHERE id=:orgId") fun streamLocationIds(orgId: Long): Flow - @Transaction - @Query("SELECT name FROM incident_organizations ORDER BY RANDOM() LIMIT 1") - fun getRandomOrganizationName(): String? - @Transaction @Query("INSERT INTO incident_organization_fts(incident_organization_fts) VALUES ('rebuild')") fun rebuildOrganizationFts() diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt index f9cc23ac..30320390 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDao.kt @@ -641,10 +641,6 @@ interface WorksiteDao { @Query("DELETE from worksites_root WHERE network_id IN(:networkWorksiteIds)") fun deleteNetworkWorksites(networkWorksiteIds: Collection) - @Transaction - @Query("SELECT case_number FROM worksites ORDER BY RANDOM() LIMIT 1") - fun getRandomWorksiteCaseNumber(): String? - @Transaction @Query("INSERT INTO worksite_text_fts_c(worksite_text_fts_c) VALUES ('rebuild')") fun rebuildWorksiteTextFts() diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt index f8311825..717c40d6 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/IncidentFts.kt @@ -11,7 +11,6 @@ import com.crisiscleanup.core.database.model.PopulatedIncidentMatch import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.IncidentIdNameType @@ -63,20 +62,7 @@ data class PopulatedIncidentIdNameMatchInfo( } } -suspend fun IncidentDaoPlus.rebuildIncidentFts(force: Boolean = false) = db.withTransaction { - with(db.incidentDao()) { - var rebuild = force - if (!force) { - getRandomIncidentName()?.let { incidentName -> - val ftsMatch = matchIncidentTokens(incidentName.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildIncidentFts() - } - } -} +fun IncidentDaoPlus.rebuildIncidentFts() = db.incidentDao().rebuildIncidentFts() suspend fun IncidentDaoPlus.getMatchingIncidents(q: String): List = coroutineScope { diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt index 5c8e12d0..990cde02 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt @@ -9,7 +9,6 @@ import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.OrganizationIdName @@ -57,21 +56,8 @@ data class PopulatedOrganizationIdNameMatchInfo( } } -suspend fun IncidentOrganizationDaoPlus.rebuildOrganizationFts(force: Boolean = false) = - db.withTransaction { - with(db.incidentOrganizationDao()) { - var rebuild = force - if (!force) { - getRandomOrganizationName()?.let { orgName -> - val ftsMatch = matchOrganizationName(orgName.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildOrganizationFts() - } - } - } +fun IncidentOrganizationDaoPlus.rebuildOrganizationFts() = + db.incidentOrganizationDao().rebuildOrganizationFts() suspend fun IncidentOrganizationDaoPlus.getMatchingOrganizations(q: String): List = coroutineScope { diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt index b01293cc..f23cec20 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/WorksiteTextFts.kt @@ -9,7 +9,6 @@ import com.crisiscleanup.core.database.dao.WorksiteDaoPlus import com.crisiscleanup.core.database.model.WorksiteEntity import com.crisiscleanup.core.database.util.ftsGlobEnds import com.crisiscleanup.core.database.util.ftsSanitize -import com.crisiscleanup.core.database.util.ftsSanitizeAsToken import com.crisiscleanup.core.database.util.intArray import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.WorkType @@ -72,21 +71,7 @@ data class PopulatedWorksiteTextMatchInfo( } } -suspend fun WorksiteDaoPlus.rebuildWorksiteTextFts(force: Boolean = false) = - db.withTransaction { - with(db.worksiteDao()) { - var rebuild = force - if (!force) { - getRandomWorksiteCaseNumber()?.let { caseNumber -> - val ftsMatch = matchSingleWorksiteTextTokens(caseNumber.ftsSanitizeAsToken) - rebuild = ftsMatch.isEmpty() - } - } - if (rebuild) { - rebuildWorksiteTextFts() - } - } - } +fun WorksiteDaoPlus.rebuildWorksiteTextFts() = db.worksiteDao().rebuildWorksiteTextFts() suspend fun WorksiteDaoPlus.getMatchingWorksites( incidentId: Long, diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto new file mode 100644 index 00000000..6e4daea0 --- /dev/null +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_maintenance.proto @@ -0,0 +1,8 @@ +syntax = "proto3"; + +option java_package = "com.crisiscleanup.core.datastore"; +option java_multiple_files = true; + +message AppMaintenance { + int64 fts_rebuild_version = 1; +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt new file mode 100644 index 00000000..23a98e91 --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt @@ -0,0 +1,23 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.DataStore +import com.crisiscleanup.core.model.data.AppMaintenanceData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppMaintenanceDataSource @Inject constructor( + private val appMaintenance: DataStore, +) { + val maintenanceData = appMaintenance.data + .map { + AppMaintenanceData( + ftsRebuildVersion = it.ftsRebuildVersion, + ) + } + + suspend fun setFtsRebuildVersion(version: Long) { + appMaintenance.updateData { + it.copy { ftsRebuildVersion = version } + } + } +} \ No newline at end of file diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt new file mode 100644 index 00000000..bdebe1ea --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializer.kt @@ -0,0 +1,25 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class AppMaintenanceSerializer @Inject constructor() : Serializer { + override val defaultValue: AppMaintenance = AppMaintenance.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): AppMaintenance = + try { + // readFrom is already called on the data store background thread + AppMaintenance.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: AppMaintenance, output: OutputStream) { + // writeTo is already called on the data store background thread + t.writeTo(output) + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt index 9ae39a21..3a59b4d6 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStoreFile import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.datastore.AccountInfoProtoSerializer +import com.crisiscleanup.core.datastore.AppMaintenanceSerializer import com.crisiscleanup.core.datastore.AppMetricsSerializer import com.crisiscleanup.core.datastore.CasesFiltersProtoSerializer import com.crisiscleanup.core.datastore.IncidentCachePreferencesSerializer @@ -87,4 +88,17 @@ object DataStoreModule { ) { context.dataStoreFile("incident_cache_preferences.pb") } + + @Provides + @Singleton + fun providesAppMaintenanceDataStore( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + serializer: AppMaintenanceSerializer, + ) = DataStoreFactory.create( + serializer = serializer, + scope = CoroutineScope(ioDispatcher + SupervisorJob()), + ) { + context.dataStoreFile("app_maintenance.pb") + } } diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt new file mode 100644 index 00000000..da4f3e98 --- /dev/null +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import kotlin.test.assertEquals + +class AppMaintenanceSerializerTest { + private val serializer = AppMaintenanceSerializer() + + @Test + fun defaultAppMaintenance_isEmpty() { + assertEquals( + appMaintenance { + // Default value + }, + serializer.defaultValue, + ) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidAppMaintenance_throwsCorruptionException() = runTest { + serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} \ No newline at end of file diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt new file mode 100644 index 00000000..936ba63d --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppMaintenanceData.kt @@ -0,0 +1,5 @@ +package com.crisiscleanup.core.model.data + +data class AppMaintenanceData( + val ftsRebuildVersion: Long, +) diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt index 1427e2b0..40a9c08b 100644 --- a/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/model/UserData.kt @@ -20,4 +20,5 @@ val UserDataNone = UserData( teamMapBounds = IncidentCoordinateBoundsNone, isWorkScreenTableView = false, isSyncMediaImmediate = false, + isMapSatelliteView = false, ) From 85aeac877d0c9afa326a4057b0b289cbf749e4cf Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 18 Sep 2025 16:55:00 -0400 Subject: [PATCH 10/23] Persist toggling of Work screen view --- .../main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 73cbbcda..6249ae20 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 @@ -219,7 +219,7 @@ class CasesViewModel @Inject constructor( get() = worksiteLocationEditor.takeEditedLocation()?.let { // TODO Separate side effect if (isTableView.value) { - isTableView.value = false + setContentViewType(false) } LatLng(it.first, it.second) From f3035b3b7b3d4e6bc99aaacc36be21f3140c47fd Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 19 Sep 2025 14:51:32 -0400 Subject: [PATCH 11/23] Query and save Incident ignore claiming thresholds --- .../core/data/model/NetworkIncident.kt | 1 + .../OfflineFirstIncidentsRepository.kt | 1 + .../48.json | 3298 +++++++++++++++++ .../core/database/CrisisCleanupDatabase.kt | 3 +- .../core/database/model/IncidentEntity.kt | 2 + .../core/network/model/NetworkIncident.kt | 2 + .../core/network/model/TestUtil.kt | 4 +- .../util/IterableStringSerializerTest.kt | 1 + 8 files changed, 3310 insertions(+), 2 deletions(-) create mode 100644 core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt index b41cfde0..249a1696 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt @@ -20,6 +20,7 @@ fun NetworkIncident.asEntity() = IncidentEntity( turnOnRelease = turnOnRelease, isArchived = isArchived ?: false, type = type, + ignoreClaimingThresholds = ignoreClaimingThresholds ?: false, ) fun NetworkIncident.locationsAsEntity(): List = diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 1063dbb3..9154537a 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -68,6 +68,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( "turn_on_release", "active_phone_number", "is_archived", + "ignore_claiming_thresholds", ) private val fullIncidentQueryFields: List = incidentsQueryFields.toMutableList().also { it.add("form_fields") } diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json new file mode 100644 index 00000000..b80aa852 --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json @@ -0,0 +1,3298 @@ +{ + "formatVersion": 1, + "database": { + "version": 48, + "identityHash": "dc62487d310fc8519e2c3f573e25a2b7", + "entities": [ + { + "tableName": "work_type_statuses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`status` TEXT NOT NULL, `name` TEXT NOT NULL, `list_order` INTEGER NOT NULL, `primary_state` TEXT NOT NULL, PRIMARY KEY(`status`))", + "fields": [ + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryState", + "columnName": "primary_state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "status" + ] + }, + "indices": [ + { + "name": "index_work_type_statuses_list_order", + "unique": false, + "columnNames": [ + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_type_statuses_list_order` ON `${TABLE_NAME}` (`list_order`)" + } + ] + }, + { + "tableName": "incidents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `start_at` INTEGER NOT NULL, `name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `case_label` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', `active_phone_number` TEXT DEFAULT '', `turn_on_release` INTEGER NOT NULL DEFAULT 0, `is_archived` INTEGER NOT NULL DEFAULT 0, `ignore_claiming_thresholds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startAt", + "columnName": "start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "caseLabel", + "columnName": "case_label", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "activePhoneNumber", + "columnName": "active_phone_number", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "turnOnRelease", + "columnName": "turn_on_release", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "is_archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ignoreClaimingThresholds", + "columnName": "ignore_claiming_thresholds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_newest_to_oldest_incidents", + "unique": false, + "columnNames": [ + "start_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_newest_to_oldest_incidents` ON `${TABLE_NAME}` (`start_at` DESC)" + } + ] + }, + { + "tableName": "incident_locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `location` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "incident_to_incident_location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `incident_location_id` INTEGER NOT NULL, PRIMARY KEY(`incident_id`, `incident_location_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`incident_location_id`) REFERENCES `incident_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentLocationId", + "columnName": "incident_location_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "incident_location_id" + ] + }, + "indices": [ + { + "name": "idx_incident_location_to_incident", + "unique": false, + "columnNames": [ + "incident_location_id", + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_incident_location_to_incident` ON `${TABLE_NAME}` (`incident_location_id`, `incident_id`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_form_fields", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `label` TEXT NOT NULL, `html_type` TEXT NOT NULL, `data_group` TEXT NOT NULL, `help` TEXT DEFAULT '', `placeholder` TEXT DEFAULT '', `read_only_break_glass` INTEGER NOT NULL, `values_default_json` TEXT DEFAULT '', `is_checkbox_default_true` INTEGER DEFAULT 0, `order_label` INTEGER NOT NULL DEFAULT -1, `validation` TEXT DEFAULT '', `recur_default` TEXT DEFAULT '0', `values_json` TEXT DEFAULT '', `is_required` INTEGER DEFAULT 0, `is_read_only` INTEGER DEFAULT 0, `list_order` INTEGER NOT NULL, `is_invalidated` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `field_parent_key` TEXT DEFAULT '', `parent_key` TEXT NOT NULL DEFAULT '', `selected_toggle_work_type` TEXT DEFAULT '', PRIMARY KEY(`incident_id`, `parent_key`, `field_key`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlType", + "columnName": "html_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataGroup", + "columnName": "data_group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "help", + "columnName": "help", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "placeholder", + "columnName": "placeholder", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "readOnlyBreakGlass", + "columnName": "read_only_break_glass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesDefaultJson", + "columnName": "values_default_json", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "isCheckboxDefaultTrue", + "columnName": "is_checkbox_default_true", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "orderLabel", + "columnName": "order_label", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "validation", + "columnName": "validation", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "recurDefault", + "columnName": "recur_default", + "affinity": "TEXT", + "defaultValue": "'0'" + }, + { + "fieldPath": "valuesJson", + "columnName": "values_json", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInvalidated", + "columnName": "is_invalidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldParentKey", + "columnName": "field_parent_key", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "parentKeyNonNull", + "columnName": "parent_key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "selectToggleWorkType", + "columnName": "selected_toggle_work_type", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "parent_key", + "field_key" + ] + }, + "indices": [ + { + "name": "index_incident_form_fields_data_group_parent_key_list_order", + "unique": false, + "columnNames": [ + "data_group", + "parent_key", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_form_fields_data_group_parent_key_list_order` ON `${TABLE_NAME}` (`data_group`, `parent_key`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `shape_type` TEXT NOT NULL DEFAULT '', `coordinates` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shapeType", + "columnName": "shape_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "coordinates", + "columnName": "coordinates", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "worksite_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + } + }, + { + "tableName": "worksites_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksites_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_worksites_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `address` TEXT NOT NULL, `auto_contact_frequency_t` TEXT, `case_number` TEXT NOT NULL, `case_number_order` INTEGER NOT NULL DEFAULT 0, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `created_at` INTEGER, `email` TEXT DEFAULT '', `favorite_id` INTEGER, `key_work_type_type` TEXT NOT NULL DEFAULT '', `key_work_type_org` INTEGER, `key_work_type_status` TEXT NOT NULL DEFAULT '', `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT, `phone1_notes` TEXT DEFAULT '', `phone2` TEXT DEFAULT '', `phone2_notes` TEXT DEFAULT '', `phone_search` TEXT DEFAULT '', `plus_code` TEXT DEFAULT '', `postal_code` TEXT NOT NULL, `reported_by` INTEGER, `state` TEXT NOT NULL, `svi` REAL, `what3Words` TEXT DEFAULT '', `updated_at` INTEGER NOT NULL, `network_photo_count` INTEGER DEFAULT 0, `is_local_favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoContactFrequencyT", + "columnName": "auto_contact_frequency_t", + "affinity": "TEXT" + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumberOrder", + "columnName": "case_number_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "favoriteId", + "columnName": "favorite_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "keyWorkTypeType", + "columnName": "key_work_type_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "keyWorkTypeOrgClaim", + "columnName": "key_work_type_org", + "affinity": "INTEGER" + }, + { + "fieldPath": "keyWorkTypeStatus", + "columnName": "key_work_type_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT" + }, + { + "fieldPath": "phone1Notes", + "columnName": "phone1_notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phone2Notes", + "columnName": "phone2_notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "phoneSearch", + "columnName": "phone_search", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "plusCode", + "columnName": "plus_code", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "postalCode", + "columnName": "postal_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportedBy", + "columnName": "reported_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "svi", + "columnName": "svi", + "affinity": "REAL" + }, + { + "fieldPath": "what3Words", + "columnName": "what3Words", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "photoCount", + "columnName": "network_photo_count", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "isLocalFavorite", + "columnName": "is_local_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksites_incident_id_latitude_longitude", + "unique": false, + "columnNames": [ + "incident_id", + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_latitude_longitude` ON `${TABLE_NAME}` (`incident_id`, `latitude`, `longitude`)" + }, + { + "name": "index_worksites_incident_id_longitude_latitude", + "unique": false, + "columnNames": [ + "incident_id", + "longitude", + "latitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_longitude_latitude` ON `${TABLE_NAME}` (`incident_id`, `longitude`, `latitude`)" + }, + { + "name": "index_worksites_incident_id_svi", + "unique": false, + "columnNames": [ + "incident_id", + "svi" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_svi` ON `${TABLE_NAME}` (`incident_id`, `svi`)" + }, + { + "name": "index_worksites_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id`, `updated_at`)" + }, + { + "name": "index_worksites_incident_id_created_at", + "unique": false, + "columnNames": [ + "incident_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_created_at` ON `${TABLE_NAME}` (`incident_id`, `created_at`)" + }, + { + "name": "index_worksites_incident_id_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_name_county_city_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "name", + "county", + "city", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_name_county_city_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `name`, `county`, `city`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_city_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "city", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_city_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `city`, `name`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_county_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "county", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_county_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `county`, `name`, `case_number_order`, `case_number`)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "work_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER, `claimed_by` INTEGER, `next_recur_at` INTEGER, `phase` INTEGER, `recur` TEXT, `status` TEXT NOT NULL, `work_type` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "orgClaim", + "columnName": "claimed_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "nextRecurAt", + "columnName": "next_recur_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "INTEGER" + }, + { + "fieldPath": "recur", + "columnName": "recur", + "affinity": "TEXT" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_work_type", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_work_type` ON `${TABLE_NAME}` (`worksite_id`, `work_type`)" + }, + { + "name": "index_work_types_worksite_id_network_id", + "unique": false, + "columnNames": [ + "worksite_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_worksite_id_network_id` ON `${TABLE_NAME}` (`worksite_id`, `network_id`)" + }, + { + "name": "index_work_types_status_worksite_id", + "unique": false, + "columnNames": [ + "status", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_status_worksite_id` ON `${TABLE_NAME}` (`status`, `worksite_id`)" + }, + { + "name": "index_work_types_claimed_by_worksite_id", + "unique": false, + "columnNames": [ + "claimed_by", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_claimed_by_worksite_id` ON `${TABLE_NAME}` (`claimed_by`, `worksite_id`)" + }, + { + "name": "index_work_types_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_network_id` ON `${TABLE_NAME}` (`network_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_form_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `is_bool_value` INTEGER NOT NULL, `value_string` TEXT NOT NULL, `value_bool` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `field_key`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isBoolValue", + "columnName": "is_bool_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valueString", + "columnName": "value_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueBool", + "columnName": "value_bool", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "field_key" + ] + }, + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_flags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `action` TEXT, `created_at` INTEGER NOT NULL, `is_high_priority` INTEGER DEFAULT 0, `notes` TEXT DEFAULT '', `reason_t` TEXT NOT NULL, `requested_action` TEXT DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHighPriority", + "columnName": "is_high_priority", + "affinity": "INTEGER", + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "defaultValue": "''" + }, + { + "fieldPath": "reasonT", + "columnName": "reason_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedAction", + "columnName": "requested_action", + "affinity": "TEXT", + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_flag", + "unique": true, + "columnNames": [ + "worksite_id", + "reason_t" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_flag` ON `${TABLE_NAME}` (`worksite_id`, `reason_t`)" + }, + { + "name": "index_worksite_flags_reason_t", + "unique": false, + "columnNames": [ + "reason_t" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_flags_reason_t` ON `${TABLE_NAME}` (`reason_t`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_global_uuid` TEXT NOT NULL DEFAULT '', `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_survivor` INTEGER NOT NULL, `note` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSurvivor", + "columnName": "is_survivor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_note", + "unique": true, + "columnNames": [ + "worksite_id", + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_note` ON `${TABLE_NAME}` (`worksite_id`, `network_id`, `local_global_uuid`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "language_translations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `translations_json` TEXT, `synced_at` INTEGER DEFAULT 0, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationsJson", + "columnName": "translations_json", + "affinity": "TEXT" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + }, + { + "tableName": "sync_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `log_time` INTEGER NOT NULL, `log_type` TEXT NOT NULL DEFAULT '', `message` TEXT NOT NULL, `details` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logTime", + "columnName": "log_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logType", + "columnName": "log_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "details", + "columnName": "details", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_logs_log_time", + "unique": false, + "columnNames": [ + "log_time" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_logs_log_time` ON `${TABLE_NAME}` (`log_time` DESC)" + } + ] + }, + { + "tableName": "worksite_changes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_version` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `change_model_version` INTEGER NOT NULL, `change_data` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `save_attempt` INTEGER NOT NULL DEFAULT 0, `archive_action` TEXT NOT NULL, `save_attempt_at` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`worksite_id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "changeModelVersion", + "columnName": "change_model_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changeData", + "columnName": "change_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveAttempt", + "columnName": "save_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "archiveAction", + "columnName": "archive_action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveAttemptAt", + "columnName": "save_attempt_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_changes_worksite_id_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_at`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt` ON `${TABLE_NAME}` (`worksite_id`, `save_attempt`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt_at_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt_at", + "created_at" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt_at_created_at` ON `${TABLE_NAME}` (`worksite_id` ASC, `save_attempt_at` ASC, `created_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `primary_location` INTEGER, `secondary_location` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryLocation", + "columnName": "primary_location", + "affinity": "INTEGER" + }, + { + "fieldPath": "secondaryLocation", + "columnName": "secondary_location", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "person_contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `email` TEXT NOT NULL, `mobile` TEXT NOT NULL, `profilePictureUri` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "first_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "last_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mobile", + "columnName": "mobile", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "profilePictureUri", + "columnName": "profilePictureUri", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "organization_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organization_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`organization_id`, `contact_id`), FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organization_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_organization", + "unique": false, + "columnNames": [ + "contact_id", + "organization_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_organization` ON `${TABLE_NAME}` (`contact_id`, `organization_id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "organization_to_affiliate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `affiliate_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `affiliate_id`), FOREIGN KEY(`id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affiliateId", + "columnName": "affiliate_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "affiliate_id" + ] + }, + "indices": [ + { + "name": "index_organization_to_affiliate_affiliate_id_id", + "unique": false, + "columnNames": [ + "affiliate_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_organization_to_affiliate_affiliate_id_id` ON `${TABLE_NAME}` (`affiliate_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organization_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `target_count` INTEGER NOT NULL, `successful_sync` INTEGER, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + } + }, + { + "tableName": "recent_worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `viewed_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewed_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recent_worksites_incident_id_viewed_at", + "unique": false, + "columnNames": [ + "incident_id", + "viewed_at" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_incident_id_viewed_at` ON `${TABLE_NAME}` (`incident_id` ASC, `viewed_at` DESC)" + }, + { + "name": "index_recent_worksites_viewed_at", + "unique": false, + "columnNames": [ + "viewed_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_viewed_at` ON `${TABLE_NAME}` (`viewed_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_work_type_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `work_type` TEXT NOT NULL, `reason` TEXT NOT NULL, `by_org` INTEGER NOT NULL, `to_org` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `approved_at` INTEGER, `rejected_at` INTEGER, `approved_rejected_reason` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "byOrg", + "columnName": "by_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toOrg", + "columnName": "to_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "approvedAt", + "columnName": "approved_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "approvedRejectedReason", + "columnName": "approved_rejected_reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_work_type_requests_worksite_id_work_type_by_org", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type", + "by_org" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_work_type_by_org` ON `${TABLE_NAME}` (`worksite_id`, `work_type`, `by_org`)" + }, + { + "name": "index_worksite_work_type_requests_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksite_work_type_requests_worksite_id_by_org", + "unique": false, + "columnNames": [ + "worksite_id", + "by_org" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_by_org` ON `${TABLE_NAME}` (`worksite_id`, `by_org`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `file_id` INTEGER NOT NULL DEFAULT 0, `file_type_t` TEXT NOT NULL, `full_url` TEXT, `large_thumbnail_url` TEXT, `mime_content_type` TEXT NOT NULL, `small_thumbnail_url` TEXT, `tag` TEXT, `title` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "file_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fileTypeT", + "columnName": "file_type_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullUrl", + "columnName": "full_url", + "affinity": "TEXT" + }, + { + "fieldPath": "largeThumbnailUrl", + "columnName": "large_thumbnail_url", + "affinity": "TEXT" + }, + { + "fieldPath": "mimeContentType", + "columnName": "mime_content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallThumbnailUrl", + "columnName": "small_thumbnail_url", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "worksite_to_network_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `network_file_id` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `network_file_id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`network_file_id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkFileId", + "columnName": "network_file_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "network_file_id" + ] + }, + "indices": [ + { + "name": "index_worksite_to_network_file_network_file_id_worksite_id", + "unique": false, + "columnNames": [ + "network_file_id", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_to_network_file_network_file_id_worksite_id` ON `${TABLE_NAME}` (`network_file_id`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "network_file_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_file_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, `rotate_degrees` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_network_file_local_images_is_deleted", + "unique": false, + "columnNames": [ + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_network_file_local_images_is_deleted` ON `${TABLE_NAME}` (`is_deleted`)" + } + ], + "foreignKeys": [ + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `worksite_id` INTEGER NOT NULL, `local_document_id` TEXT NOT NULL, `uri` TEXT NOT NULL, `tag` TEXT NOT NULL, `rotate_degrees` INTEGER NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "local_document_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_local_images_worksite_id_local_document_id", + "unique": true, + "columnNames": [ + "worksite_id", + "local_document_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_local_images_worksite_id_local_document_id` ON `${TABLE_NAME}` (`worksite_id`, `local_document_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_worksites_full_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `synced_at` INTEGER, `center_my_location` INTEGER NOT NULL, `center_latitude` REAL NOT NULL DEFAULT 999, `center_longitude` REAL NOT NULL DEFAULT 999, `query_area_radius` REAL NOT NULL, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "isMyLocationCentered", + "columnName": "center_my_location", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "center_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "longitude", + "columnName": "center_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "radius", + "columnName": "query_area_radius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "tableName": "incident_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', content=`incidents`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incidents", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_UPDATE BEFORE UPDATE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_DELETE BEFORE DELETE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_UPDATE AFTER UPDATE ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_INSERT AFTER INSERT ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END" + ] + }, + { + "tableName": "incident_organization_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, content=`incident_organizations`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incident_organizations", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_UPDATE BEFORE UPDATE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_DELETE BEFORE DELETE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_UPDATE AFTER UPDATE ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_INSERT AFTER INSERT ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ] + }, + { + "tableName": "case_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `created_by` INTEGER NOT NULL, `event_key` TEXT NOT NULL, `past_tense_t` TEXT NOT NULL, `actor_location_name` TEXT NOT NULL, `recipient_location_name` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventKey", + "columnName": "event_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pastTenseT", + "columnName": "past_tense_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorLocationName", + "columnName": "actor_location_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientLocationName", + "columnName": "recipient_location_name", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_case_history_events_worksite_id_created_by_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_by", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_case_history_events_worksite_id_created_by_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_by`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "case_history_event_attrs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_name` TEXT NOT NULL, `patient_case_number` TEXT, `patient_id` INTEGER NOT NULL, `patient_label_t` TEXT, `patient_location_name` TEXT, `patient_name_t` TEXT, `patient_reason_t` TEXT, `patient_status_name_t` TEXT, `recipient_case_number` TEXT, `recipient_id` INTEGER, `recipient_name` TEXT, `recipient_name_t` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `case_history_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentName", + "columnName": "incident_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCaseNumber", + "columnName": "patient_case_number", + "affinity": "TEXT" + }, + { + "fieldPath": "patientId", + "columnName": "patient_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientLabelT", + "columnName": "patient_label_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientLocationName", + "columnName": "patient_location_name", + "affinity": "TEXT" + }, + { + "fieldPath": "patientNameT", + "columnName": "patient_name_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientReasonT", + "columnName": "patient_reason_t", + "affinity": "TEXT" + }, + { + "fieldPath": "patientStatusNameT", + "columnName": "patient_status_name_t", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientCaseNumber", + "columnName": "recipient_case_number", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientId", + "columnName": "recipient_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "recipientName", + "columnName": "recipient_name", + "affinity": "TEXT" + }, + { + "fieldPath": "recipientNameT", + "columnName": "recipient_name_t", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "case_history_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "person_to_organization", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `organization_id`), FOREIGN KEY(`id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "organization_id" + ] + }, + "indices": [ + { + "name": "index_person_to_organization_organization_id_id", + "unique": false, + "columnNames": [ + "organization_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_person_to_organization_organization_id_id` ON `${TABLE_NAME}` (`organization_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_text_fts_c", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`address` TEXT NOT NULL, `case_number` TEXT NOT NULL, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `email` TEXT NOT NULL, `name` TEXT NOT NULL, `phone_search` TEXT NOT NULL DEFAULT '', content=`worksites`)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneSearch", + "columnName": "phone_search", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "worksites", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_BEFORE_UPDATE BEFORE UPDATE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_c` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_BEFORE_DELETE BEFORE DELETE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_c` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_AFTER_UPDATE AFTER UPDATE ON `worksites` BEGIN INSERT INTO `worksite_text_fts_c`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone_search`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone_search`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_c_AFTER_INSERT AFTER INSERT ON `worksites` BEGIN INSERT INTO `worksite_text_fts_c`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone_search`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone_search`); END" + ] + }, + { + "tableName": "incident_worksites_secondary_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER" + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "tableName": "lists", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `local_global_uuid` TEXT NOT NULL DEFAULT '', `created_by` INTEGER, `updated_by` INTEGER, `created_at` INTEGER NOT NULL, `updated_at` INTEGER NOT NULL, `parent` INTEGER, `name` TEXT NOT NULL, `description` TEXT, `list_order` INTEGER, `tags` TEXT, `model` TEXT NOT NULL, `object_ids` TEXT NOT NULL DEFAULT '', `shared` TEXT NOT NULL, `permissions` TEXT NOT NULL, `incident_id` INTEGER, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE NO ACTION )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "updatedBy", + "columnName": "updated_by", + "affinity": "INTEGER" + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER" + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "objectIds", + "columnName": "object_ids", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "shared", + "columnName": "shared", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "permissions", + "columnName": "permissions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_lists_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_lists_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_lists_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id` DESC, `updated_at` DESC)" + }, + { + "name": "index_lists_updated_at", + "unique": false, + "columnNames": [ + "updated_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_updated_at` ON `${TABLE_NAME}` (`updated_at` DESC)" + }, + { + "name": "index_lists_model_updated_at", + "unique": false, + "columnNames": [ + "model", + "updated_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_model_updated_at` ON `${TABLE_NAME}` (`model` DESC, `updated_at` DESC)" + }, + { + "name": "index_lists_parent_list_order", + "unique": false, + "columnNames": [ + "parent", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_parent_list_order` ON `${TABLE_NAME}` (`parent`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "NO ACTION", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "teams_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_teams_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_teams_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_teams_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_teams_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "teams", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `notes` TEXT NOT NULL, `color` TEXT NOT NULL, `case_count` INTEGER NOT NULL, `case_complete_count` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `teams_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "color", + "columnName": "color", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseCount", + "columnName": "case_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completeCount", + "columnName": "case_complete_count", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_teams_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_teams_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_teams_incident_id_name", + "unique": false, + "columnNames": [ + "incident_id", + "name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_teams_incident_id_name` ON `${TABLE_NAME}` (`incident_id`, `name`)" + } + ], + "foreignKeys": [ + { + "table": "teams_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "team_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`team_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`team_id`, `contact_id`), FOREIGN KEY(`team_id`) REFERENCES `teams`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "teamId", + "columnName": "team_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "team_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_team", + "unique": false, + "columnNames": [ + "contact_id", + "team_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_team` ON `${TABLE_NAME}` (`contact_id`, `team_id`)" + } + ], + "foreignKeys": [ + { + "table": "teams", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "team_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_data_sync_parameters", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `updated_before` INTEGER NOT NULL, `updated_after` INTEGER NOT NULL, `full_updated_before` INTEGER NOT NULL, `full_updated_after` INTEGER NOT NULL, `bounded_region` TEXT NOT NULL, `bounded_synced_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedBefore", + "columnName": "updated_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAfter", + "columnName": "updated_after", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalUpdatedBefore", + "columnName": "full_updated_before", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "additionalUpdatedAfter", + "columnName": "full_updated_after", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "boundedRegion", + "columnName": "bounded_region", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "boundedSyncedAt", + "columnName": "bounded_synced_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "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, 'dc62487d310fc8519e2c3f573e25a2b7')" + ] + } +} \ 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 c2afba1d..f1e6d921 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 @@ -119,7 +119,7 @@ import com.crisiscleanup.core.database.util.InstantConverter TeamMemberCrossRef::class, IncidentDataSyncParametersEntity::class, ], - version = 47, + version = 48, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -167,6 +167,7 @@ import com.crisiscleanup.core.database.util.InstantConverter AutoMigration(from = 44, to = 45), AutoMigration(from = 45, to = 46, spec = Schema45To46::class), AutoMigration(from = 46, to = 47), + AutoMigration(from = 47, to = 48), ], exportSchema = true, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt index 8a167465..e51b1a16 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt @@ -39,6 +39,8 @@ data class IncidentEntity( val turnOnRelease: Boolean = false, @ColumnInfo("is_archived", defaultValue = "0") val isArchived: Boolean = false, + @ColumnInfo("ignore_claiming_thresholds", defaultValue = "0") + val ignoreClaimingThresholds: Boolean = false, ) @Entity("incident_locations") diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt index de99a9b2..ef698c01 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt @@ -47,6 +47,8 @@ data class NetworkIncident( val turnOnRelease: Boolean, @SerialName("is_archived") val isArchived: Boolean?, + @SerialName("ignore_claiming_thresholds") + val ignoreClaimingThresholds: Boolean? = null, @SerialName("form_fields") val fields: List? = null, diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt index 81d6646e..a1b63591 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/TestUtil.kt @@ -33,6 +33,7 @@ internal fun fillNetworkIncident( activePhone: List? = null, turnOnRelease: Boolean = false, isArchived: Boolean = false, + ignoreClaimingThresholds: Boolean? = null, ) = NetworkIncident( id, Instant.parse(startAt), @@ -43,7 +44,8 @@ internal fun fillNetworkIncident( incidentType, activePhone, turnOnRelease, - isArchived, + isArchived = isArchived, + ignoreClaimingThresholds = ignoreClaimingThresholds, ) internal fun fillNetworkLocation( diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt index a6605323..ef666b35 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/util/IterableStringSerializerTest.kt @@ -118,6 +118,7 @@ class IterableStringSerializerTest { phoneNumbers, false, null, + null, emptyList(), ) } From 3ff807efb9e4859c980520a604ad018e1d781df3 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 19 Sep 2025 15:14:09 -0400 Subject: [PATCH 12/23] Add database table for storing Case claim thresholds against user+Incident --- .../48.json | 54 ++++++++++++++++++- .../database/TestCrisisCleanupDatabase.kt | 2 + .../core/database/CrisisCleanupDatabase.kt | 2 + .../model/IncidentClaimThresholdEntity.kt | 28 ++++++++++ .../datastore/AppMaintenanceDataSource.kt | 2 +- .../datastore/AppMaintenanceSerializerTest.kt | 2 +- 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json index b80aa852..6890f070 100644 --- a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "dc62487d310fc8519e2c3f573e25a2b7", + "identityHash": "91fe5184896521095774e8897ddf564e", "entities": [ { "tableName": "work_type_statuses", @@ -3288,11 +3288,61 @@ ] } ] + }, + { + "tableName": "incident_claim_thresholds", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `user_id` INTEGER NOT NULL, `user_claim_count` INTEGER NOT NULL, `user_close_ratio` REAL NOT NULL, PRIMARY KEY(`incident_id`, `user_id`), 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": "userId", + "columnName": "user_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userClaimCount", + "columnName": "user_claim_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userCloseRatio", + "columnName": "user_close_ratio", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "user_id" + ] + }, + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] } ], "setupQueries": [ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", - "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dc62487d310fc8519e2c3f573e25a2b7')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '91fe5184896521095774e8897ddf564e')" ] } } \ No newline at end of file diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt index f9bcabb0..0ed021e0 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestCrisisCleanupDatabase.kt @@ -11,6 +11,7 @@ import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity import com.crisiscleanup.core.database.model.CaseHistoryEventAttrEntity import com.crisiscleanup.core.database.model.CaseHistoryEventEntity +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentDataSyncParametersEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity @@ -95,6 +96,7 @@ import kotlinx.datetime.Instant TeamEntity::class, TeamMemberCrossRef::class, IncidentDataSyncParametersEntity::class, + IncidentClaimThresholdEntity::class, ], version = 1, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt index f1e6d921..e5f78c0d 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt @@ -39,6 +39,7 @@ import com.crisiscleanup.core.database.dao.fts.IncidentOrganizationFtsEntity import com.crisiscleanup.core.database.dao.fts.WorksiteTextFtsEntity import com.crisiscleanup.core.database.model.CaseHistoryEventAttrEntity import com.crisiscleanup.core.database.model.CaseHistoryEventEntity +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentDataSyncParametersEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity @@ -118,6 +119,7 @@ import com.crisiscleanup.core.database.util.InstantConverter TeamEntity::class, TeamMemberCrossRef::class, IncidentDataSyncParametersEntity::class, + IncidentClaimThresholdEntity::class, ], version = 48, autoMigrations = [ diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt new file mode 100644 index 00000000..e7c4988e --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt @@ -0,0 +1,28 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + +@Entity( + "incident_claim_thresholds", + primaryKeys = ["incident_id", "user_id"], + foreignKeys = [ + ForeignKey( + entity = IncidentEntity::class, + parentColumns = ["id"], + childColumns = ["incident_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class IncidentClaimThresholdEntity( + @ColumnInfo("incident_id") + val incidentId: Long, + @ColumnInfo("user_id") + val userId: Long, + @ColumnInfo("user_claim_count") + val userClaimCount: Int, + @ColumnInfo("user_close_ratio") + val userCloseRatio: Double, +) diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt index 23a98e91..a52c3b36 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppMaintenanceDataSource.kt @@ -20,4 +20,4 @@ class AppMaintenanceDataSource @Inject constructor( it.copy { ftsRebuildVersion = version } } } -} \ No newline at end of file +} diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt index da4f3e98..949aed07 100644 --- a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppMaintenanceSerializerTest.kt @@ -23,4 +23,4 @@ class AppMaintenanceSerializerTest { fun readingInvalidAppMaintenance_throwsCorruptionException() = runTest { serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) } -} \ No newline at end of file +} From f2a7caf13ea43832b30d2c696657aab03c250293 Mon Sep 17 00:00:00 2001 From: hue Date: Sun, 21 Sep 2025 23:23:42 -0400 Subject: [PATCH 13/23] Query and cache Incident claim thresholds --- .../crisiscleanup/MainActivityViewModel.kt | 1 + .../crisiscleanup/core/common/sync/Syncer.kt | 3 ++ .../crisiscleanup/core/data/di/DataModule.kt | 5 ++++ .../data/repository/AccountDataRefresher.kt | 22 ++++++++++++++ .../data/repository/AppConfigRepository.kt | 22 ++++++++++++++ .../data/repository/IncidentsRepository.kt | 6 ++++ .../OfflineFirstIncidentsRepository.kt | 12 ++++++++ .../48.json | 29 +++++++++++++------ .../core/database/dao/IncidentDao.kt | 13 +++++++++ .../core/database/dao/IncidentDaoPlus.kt | 21 ++++++++++++++ .../model/IncidentClaimThresholdEntity.kt | 14 ++++++--- .../crisiscleanup/core/data/app_config.proto | 9 ++++++ .../core/data/user_preferences.proto | 2 +- .../core/datastore/AppConfigDataSource.kt | 27 +++++++++++++++++ .../core/datastore/AppConfigSerializer.kt | 25 ++++++++++++++++ .../core/datastore/di/DataStoreModule.kt | 14 +++++++++ .../core/datastore/AppConfigSerializerTest.kt | 26 +++++++++++++++++ .../core/model/data/AppConfigData.kt | 6 ++++ .../core/model/data/IncidentClaimThreshold.kt | 7 +++++ .../network/CrisisCleanupNetworkDataSource.kt | 3 ++ .../model/NetworkAccountProfileResult.kt | 16 ++++++++++ .../core/network/model/NetworkPortalConfig.kt | 16 ++++++++++ .../core/network/retrofit/DataApiClient.kt | 6 ++++ .../java/com/crisiscleanup/sync/AppSyncer.kt | 21 ++++++++++++++ .../crisiscleanup/sync/workers/SyncWorker.kt | 3 ++ 25 files changed, 315 insertions(+), 14 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt create mode 100644 core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto create mode 100644 core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt create mode 100644 core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt create mode 100644 core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 6897f107..858c1bc2 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -206,6 +206,7 @@ class MainActivityViewModel @Inject constructor( syncPuller.appPullLanguage() syncPuller.appPullStatuses() + syncPuller.appPullAppConfig() syncPusher.scheduleSyncMedia() diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt index 70d9875a..fa7a6790 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt @@ -36,6 +36,9 @@ interface SyncPuller { fun appPullStatuses() suspend fun syncPullStatuses(): SyncResult + fun appPullAppConfig() + suspend fun syncPullAppConfig(): SyncResult + fun appPullIncidents() = appPullIncidentData( cancelOngoing = true, forcePullIncidents = true, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt index 001c2258..4e5d5ffc 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 @@ -8,6 +8,7 @@ import com.crisiscleanup.core.data.IncidentSelectManager import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository +import com.crisiscleanup.core.data.repository.AppConfigRepository import com.crisiscleanup.core.data.repository.AppDataManagementRepository import com.crisiscleanup.core.data.repository.AppEndOfLifeRepository import com.crisiscleanup.core.data.repository.AppMetricsRepository @@ -16,6 +17,7 @@ import com.crisiscleanup.core.data.repository.CaseHistoryRepository import com.crisiscleanup.core.data.repository.CasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountDataRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountUpdateRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupAppConfigRepository import com.crisiscleanup.core.data.repository.CrisisCleanupCasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupDataManagementRepository import com.crisiscleanup.core.data.repository.CrisisCleanupListsRepository @@ -215,4 +217,7 @@ interface DataModule { fun bindsIncidentCacheRepository( repository: IncidentWorksitesCacheRepository, ): IncidentCacheRepository + + @Binds + fun appConfigRepository(repository: CrisisCleanupAppConfigRepository): AppConfigRepository } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt index dfdbec8a..de91c3e8 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt @@ -7,6 +7,7 @@ import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.profilePictureUrl import kotlinx.coroutines.CoroutineDispatcher @@ -25,6 +26,7 @@ class AccountDataRefresher @Inject constructor( private val networkDataSource: CrisisCleanupNetworkDataSource, private val accountDataRepository: AccountDataRepository, private val organizationsRepository: OrganizationsRepository, + private val incidentsRepository: IncidentsRepository, private val accountEventBus: AccountEventBus, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, @@ -58,6 +60,26 @@ class AccountDataRefresher @Inject constructor( profile.activeRoles!!, ) + profile.internalState?.incidentThresholdLookup?.let { + val incidentThresholds = it.mapNotNull { entry -> + entry.key.toLongOrNull()?.let { incidentId -> + val thresholds = entry.value + if (thresholds.claimedCount != null && thresholds.closedRatio != null) { + return@mapNotNull IncidentClaimThreshold( + incidentId = incidentId, + claimedCount = thresholds.claimedCount!!, + closedRatio = thresholds.closedRatio!!, + ) + } + } + null + } + incidentsRepository.saveIncidentClaimThresholds( + accountId, + incidentThresholds, + ) + } + accountDataUpdateTime = Clock.System.now() } } catch (e: Exception) { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt new file mode 100644 index 00000000..6d4214ee --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.datastore.AppConfigDataSource +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import javax.inject.Inject + +interface AppConfigRepository { + suspend fun pullAppConfig() +} + +class CrisisCleanupAppConfigRepository @Inject constructor( + private val networkDataSource: CrisisCleanupNetworkDataSource, + private val appConfigDataSource: AppConfigDataSource, +) : AppConfigRepository { + override suspend fun pullAppConfig() { + val thresholds = networkDataSource.getClaimThresholds() + appConfigDataSource.setClaimThresholds( + thresholds.workTypeCount, + thresholds.workTypeClosedRatio, + ) + } +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt index 3ffe987b..c2509d5e 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt @@ -1,6 +1,7 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.model.data.IncidentIdNameType import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant @@ -37,4 +38,9 @@ interface IncidentsRepository { suspend fun pullIncidentOrganizations(incidentId: Long, force: Boolean = false) suspend fun getMatchingIncidents(q: String): List + + suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 9154537a..be006382 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -23,6 +23,7 @@ import com.crisiscleanup.core.datastore.LocalAppPreferencesDataSource import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.INCIDENT_ORGANIZATIONS_STABLE_MODEL_BUILD_VERSION import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.model.data.IncidentIdNameType import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkIncident @@ -280,4 +281,15 @@ class OfflineFirstIncidentsRepository @Inject constructor( } override suspend fun getMatchingIncidents(q: String) = incidentDaoPlus.getMatchingIncidents(q) + + override suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) { + try { + incidentDaoPlus.saveIncidentThresholds(accountId, incidentThresholds) + } catch (e: Exception) { + logger.logException(e) + } + } } diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json index 6890f070..d839c409 100644 --- a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "91fe5184896521095774e8897ddf564e", + "identityHash": "4686c49696fda2353872c0de154f8605", "entities": [ { "tableName": "work_type_statuses", @@ -3291,17 +3291,17 @@ }, { "tableName": "incident_claim_thresholds", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `user_id` INTEGER NOT NULL, `user_claim_count` INTEGER NOT NULL, `user_close_ratio` REAL NOT NULL, PRIMARY KEY(`incident_id`, `user_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `user_claim_count` INTEGER NOT NULL, `user_close_ratio` REAL NOT NULL, PRIMARY KEY(`user_id`, `incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { - "fieldPath": "incidentId", - "columnName": "incident_id", + "fieldPath": "userId", + "columnName": "user_id", "affinity": "INTEGER", "notNull": true }, { - "fieldPath": "userId", - "columnName": "user_id", + "fieldPath": "incidentId", + "columnName": "incident_id", "affinity": "INTEGER", "notNull": true }, @@ -3321,10 +3321,21 @@ "primaryKey": { "autoGenerate": false, "columnNames": [ - "incident_id", - "user_id" + "user_id", + "incident_id" ] }, + "indices": [ + { + "name": "index_incident_claim_thresholds_incident_id", + "unique": false, + "columnNames": [ + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_claim_thresholds_incident_id` ON `${TABLE_NAME}` (`incident_id`)" + } + ], "foreignKeys": [ { "table": "incidents", @@ -3342,7 +3353,7 @@ ], "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, '91fe5184896521095774e8897ddf564e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '4686c49696fda2353872c0de154f8605')" ] } } \ No newline at end of file diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index 323400ac..7e5473eb 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -7,6 +7,7 @@ import androidx.room.Query import androidx.room.Transaction import androidx.room.Upsert import com.crisiscleanup.core.database.dao.fts.PopulatedIncidentIdNameMatchInfo +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef @@ -96,6 +97,18 @@ interface IncidentDao { validFieldKeys: Set, ) + @Transaction + @Query( + """ + DELETE FROM incident_claim_thresholds + WHERE user_id=:accountId AND incident_id NOT IN(:incidentIds) + """, + ) + suspend fun deleteUnspecifiedClaimThresholds(accountId: Long, incidentIds: Collection) + + @Upsert + suspend fun upsertIncidentClaimThresholds(claimThresholds: List) + @Upsert suspend fun upsertFormFields(formFields: Collection) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt index b1a631e5..70df8e2d 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDaoPlus.kt @@ -2,10 +2,12 @@ package com.crisiscleanup.core.database.dao import androidx.room.withTransaction import com.crisiscleanup.core.database.CrisisCleanupDatabase +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity import com.crisiscleanup.core.database.model.IncidentEntity import com.crisiscleanup.core.database.model.IncidentFormFieldEntity import com.crisiscleanup.core.database.model.IncidentIncidentLocationCrossRef import com.crisiscleanup.core.database.model.IncidentLocationEntity +import com.crisiscleanup.core.model.data.IncidentClaimThreshold import javax.inject.Inject class IncidentDaoPlus @Inject constructor( @@ -54,4 +56,23 @@ class IncidentDaoPlus @Inject constructor( incidents.forEach { updateFormFields(it.first, it.second) } } } + + suspend fun saveIncidentThresholds( + accountId: Long, + incidentThresholds: List, + ) = + db.withTransaction { + val incidentDao = db.incidentDao() + val incidentIds = incidentThresholds.map(IncidentClaimThreshold::incidentId) + incidentDao.deleteUnspecifiedClaimThresholds(accountId, incidentIds) + val entities = incidentThresholds.map { + IncidentClaimThresholdEntity( + userId = accountId, + incidentId = it.incidentId, + userClaimCount = it.claimedCount, + userCloseRatio = it.closedRatio, + ) + } + incidentDao.upsertIncidentClaimThresholds(entities) + } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt index e7c4988e..ab45405e 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentClaimThresholdEntity.kt @@ -3,10 +3,11 @@ package com.crisiscleanup.core.database.model import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey +import androidx.room.Index @Entity( "incident_claim_thresholds", - primaryKeys = ["incident_id", "user_id"], + primaryKeys = ["user_id", "incident_id"], foreignKeys = [ ForeignKey( entity = IncidentEntity::class, @@ -15,14 +16,19 @@ import androidx.room.ForeignKey onDelete = ForeignKey.CASCADE, ), ], + indices = [ + Index( + value = ["incident_id"], + ), + ], ) data class IncidentClaimThresholdEntity( - @ColumnInfo("incident_id") - val incidentId: Long, @ColumnInfo("user_id") val userId: Long, + @ColumnInfo("incident_id") + val incidentId: Long, @ColumnInfo("user_claim_count") val userClaimCount: Int, @ColumnInfo("user_close_ratio") - val userCloseRatio: Double, + val userCloseRatio: Float, ) diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto new file mode 100644 index 00000000..6b77e90f --- /dev/null +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/app_config.proto @@ -0,0 +1,9 @@ +syntax = "proto3"; + +option java_package = "com.crisiscleanup.core.datastore"; +option java_multiple_files = true; + +message AppConfig { + int32 claimed_work_type_count_threshold = 1; + float claimed_work_type_closed_ratio_threshold = 2; +} diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index 4f375bf4..9ee57f48 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -22,7 +22,7 @@ message UserPreferences { int64 selected_incident_id = 4; - // Deprecated since OAuth and other auth options was added + // Deprecated since OAuth and other auth options are available int32 save_credentials_prompt_count = 5 [deprecated = true]; bool disable_save_credentials_prompt = 6 [deprecated = true]; diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt new file mode 100644 index 00000000..568308eb --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigDataSource.kt @@ -0,0 +1,27 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.DataStore +import com.crisiscleanup.core.model.data.AppConfigData +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +class AppConfigDataSource @Inject constructor( + private val appConfig: DataStore, +) { + val appConfigData = appConfig.data + .map { + AppConfigData( + claimCountThreshold = it.claimedWorkTypeCountThreshold, + closedClaimRatioThreshold = it.claimedWorkTypeClosedRatioThreshold, + ) + } + + suspend fun setClaimThresholds(count: Int, ratio: Float) { + appConfig.updateData { + it.copy { + claimedWorkTypeCountThreshold = count + claimedWorkTypeClosedRatioThreshold = ratio + } + } + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt new file mode 100644 index 00000000..831bdc72 --- /dev/null +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/AppConfigSerializer.kt @@ -0,0 +1,25 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.protobuf.InvalidProtocolBufferException +import java.io.InputStream +import java.io.OutputStream +import javax.inject.Inject + +class AppConfigSerializer @Inject constructor() : Serializer { + override val defaultValue: AppConfig = AppConfig.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): AppConfig = + try { + // readFrom is already called on the data store background thread + AppConfig.parseFrom(input) + } catch (exception: InvalidProtocolBufferException) { + throw CorruptionException("Cannot read proto.", exception) + } + + override suspend fun writeTo(t: AppConfig, output: OutputStream) { + // writeTo is already called on the data store background thread + t.writeTo(output) + } +} diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt index 3a59b4d6..8a4e265d 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/di/DataStoreModule.kt @@ -6,6 +6,7 @@ import androidx.datastore.dataStoreFile import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.datastore.AccountInfoProtoSerializer +import com.crisiscleanup.core.datastore.AppConfigSerializer import com.crisiscleanup.core.datastore.AppMaintenanceSerializer import com.crisiscleanup.core.datastore.AppMetricsSerializer import com.crisiscleanup.core.datastore.CasesFiltersProtoSerializer @@ -101,4 +102,17 @@ object DataStoreModule { ) { context.dataStoreFile("app_maintenance.pb") } + + @Provides + @Singleton + fun providesAppConfigDataStore( + @ApplicationContext context: Context, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + serializer: AppConfigSerializer, + ) = DataStoreFactory.create( + serializer = serializer, + scope = CoroutineScope(ioDispatcher + SupervisorJob()), + ) { + context.dataStoreFile("app_config.pb") + } } diff --git a/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt new file mode 100644 index 00000000..f41b5ba7 --- /dev/null +++ b/core/datastore/src/test/java/com/crisiscleanup/core/datastore/AppConfigSerializerTest.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.core.datastore + +import androidx.datastore.core.CorruptionException +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.ByteArrayInputStream +import kotlin.test.assertEquals + +class AppConfigSerializerTest { + private val serializer = AppConfigSerializer() + + @Test + fun defaultAppConfig_isEmpty() { + assertEquals( + appConfig { + // Default value + }, + serializer.defaultValue, + ) + } + + @Test(expected = CorruptionException::class) + fun readingInvalidAppConfig_throwsCorruptionException() = runTest { + serializer.readFrom(ByteArrayInputStream(byteArrayOf(0))) + } +} diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt new file mode 100644 index 00000000..47036540 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/AppConfigData.kt @@ -0,0 +1,6 @@ +package com.crisiscleanup.core.model.data + +data class AppConfigData( + val claimCountThreshold: Int, + val closedClaimRatioThreshold: Float, +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt new file mode 100644 index 00000000..3fcbb6aa --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/IncidentClaimThreshold.kt @@ -0,0 +1,7 @@ +package com.crisiscleanup.core.model.data + +data class IncidentClaimThreshold( + val incidentId: Long, + val claimedCount: Int, + val closedRatio: Float, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index c6a8b5ba..d7ca5671 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -2,6 +2,7 @@ package com.crisiscleanup.core.network import com.crisiscleanup.core.network.model.NetworkAccountProfileResult import com.crisiscleanup.core.network.model.NetworkCaseHistoryEvent +import com.crisiscleanup.core.network.model.NetworkClaimThreshold import com.crisiscleanup.core.network.model.NetworkCountResult import com.crisiscleanup.core.network.model.NetworkFlagsFormData import com.crisiscleanup.core.network.model.NetworkFlagsFormDataResult @@ -226,4 +227,6 @@ interface CrisisCleanupNetworkDataSource { ): NetworkTeamResult suspend fun getWorksiteChanges(after: Instant): List + + suspend fun getClaimThresholds(): NetworkClaimThreshold } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt index 914451b3..314c06aa 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAccountProfileResult.kt @@ -19,4 +19,20 @@ data class NetworkAccountProfileResult( val organization: NetworkOrganizationShort?, @SerialName("active_roles") val activeRoles: Set?, + @SerialName("internal_state") + val internalState: NetworkProfileInternalState?, +) + +@Serializable +data class NetworkProfileInternalState( + @SerialName("incidents") + val incidentThresholdLookup: Map, +) + +@Serializable +data class NetworkIncidentClaimThreshold( + @SerialName("claimed_work_type_count") + val claimedCount: Int?, + @SerialName("claimed_work_type_closed_ratio") + val closedRatio: Float?, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt new file mode 100644 index 00000000..fe4c09dd --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPortalConfig.kt @@ -0,0 +1,16 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkPortalConfig( + val attr: NetworkClaimThreshold, +) + +@Serializable data class NetworkClaimThreshold( + @SerialName("claimed_work_type_count_threshold") + val workTypeCount: Int, + @SerialName("claimed_work_type_closed_ratio_threshold") + val workTypeClosedRatio: Float, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 0eb4b4eb..00ee3e31 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.network.model.NetworkListsResult import com.crisiscleanup.core.network.model.NetworkLocationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsSearchResult +import com.crisiscleanup.core.network.model.NetworkPortalConfig import com.crisiscleanup.core.network.model.NetworkRedeployRequestsResult import com.crisiscleanup.core.network.model.NetworkTeamResult import com.crisiscleanup.core.network.model.NetworkUserProfile @@ -384,6 +385,9 @@ private interface DataSourceApi { @Query("since") after: Instant, ): NetworkWorksiteChangesResult + + @GET("portals/current") + suspend fun getCurrentPortalConfig(): NetworkPortalConfig } private val worksiteCoreDataFields = listOf( @@ -721,4 +725,6 @@ class DataApiClient @Inject constructor( } return result.changes ?: emptyList() } + + override suspend fun getClaimThresholds() = networkApi.getCurrentPortalConfig().attr } diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt index 233f1410..731b1797 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -14,6 +14,7 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.sync.SyncResult import com.crisiscleanup.core.data.incidentcache.IncidentDataPullReporter import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.AppConfigRepository import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentCacheRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -42,6 +43,7 @@ class AppSyncer @Inject constructor( incidentDataPullReporter: IncidentDataPullReporter, private val languageRepository: LanguageTranslationsRepository, private val statusRepository: WorkTypeStatusRepository, + private val appConfigRepository: AppConfigRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val appPreferencesRepository: AppPreferencesRepository, private val networkMonitor: NetworkMonitor, @@ -215,6 +217,25 @@ class AppSyncer @Inject constructor( } } + override fun appPullAppConfig() { + applicationScope.launch(ioDispatcher) { + syncPullAppConfig() + } + } + + override suspend fun syncPullAppConfig(): SyncResult { + onlinePrecondition()?.let { + return it + } + + return try { + appConfigRepository.pullAppConfig() + SyncResult.Success("App config pulled") + } catch (e: Exception) { + SyncResult.Error(e.message ?: "App config pull fail") + } + } + override fun appPushWorksite(worksiteId: Long, scheduleMediaSync: Boolean) { applicationScope.launch(ioDispatcher) { pushPrecondition()?.let { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt index 7c0124c7..2e4193a8 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorker.kt @@ -58,6 +58,9 @@ internal class SyncWorker @AssistedInject constructor( async { syncPuller.syncPullStatuses() is SyncResult.Success }, + async { + syncPuller.syncPullAppConfig() is SyncResult.Success + }, ).all { it } syncLogger From e6f9c45f7cc1ce80dab14d074a1cb5f297dc652f Mon Sep 17 00:00:00 2001 From: hue Date: Sun, 21 Sep 2025 23:29:09 -0400 Subject: [PATCH 14/23] Update method name to reflect multiple operations --- app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt | 2 +- .../core/data/repository/AccountDataRefresher.kt | 5 +++-- .../core/data/repository/IncidentCacheRepository.kt | 2 +- .../feature/organizationmanage/RequestRedeployViewModel.kt | 2 +- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 858c1bc2..c19fb31f 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -172,7 +172,7 @@ class MainActivityViewModel @Inject constructor( syncFullWorksites = false, ) accountDataRefresher.updateMyOrganization(true) - accountDataRefresher.updateApprovedIncidents() + accountDataRefresher.updateProfileIncidentsData() logger.setAccountId(it.id.toString()) } else { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt index de91c3e8..da9ab348 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt @@ -102,7 +102,8 @@ class AccountDataRefresher @Inject constructor( refreshAccountData("accept terms", true) } - suspend fun updateApprovedIncidents(force: Boolean = false) { - refreshAccountData("approved incidents", force) + // Approved Incidents and Incident thresholds + suspend fun updateProfileIncidentsData(force: Boolean = false) { + refreshAccountData("profile incidents data", force) } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt index e9102a4c..c7a46ae4 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentCacheRepository.kt @@ -300,7 +300,7 @@ class IncidentWorksitesCacheRepository @Inject constructor( if (syncPlan.syncIncidents) { logStage(incidentId, IncidentCacheStage.Incidents) - accountDataRefresher.updateApprovedIncidents(true) + accountDataRefresher.updateProfileIncidentsData(true) incidentsRepository.pullIncidents(true) } diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt index 144bf692..78bd0d70 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt @@ -72,7 +72,7 @@ class RequestRedeployViewModel @Inject constructor( init { viewModelScope.launch(ioDispatcher) { - accountDataRefresher.updateApprovedIncidents(true) + accountDataRefresher.updateProfileIncidentsData(true) requestedIncidentsStream.value = requestRedeployRepository.getRequestedIncidents() From d50d3e14cb2fc205ea7b5959ec1fd2717e2dfde6 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 22 Sep 2025 12:52:54 -0400 Subject: [PATCH 15/23] Start on Incident claim threshold determination --- .../crisiscleanup/core/data/di/DataModule.kt | 9 +- .../data/repository/AccountDataRefresher.kt | 4 +- .../data/repository/AppConfigRepository.kt | 6 ++ .../IncidentClaimThresholdRepository.kt | 87 +++++++++++++++++++ .../data/repository/IncidentsRepository.kt | 6 -- .../OfflineFirstIncidentsRepository.kt | 12 --- .../core/database/dao/IncidentDao.kt | 12 +++ .../caseeditor/CreateEditCaseViewModel.kt | 15 +++- 8 files changed, 128 insertions(+), 23 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt 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 4e5d5ffc..941919c2 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 @@ -20,6 +20,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupAccountUpdateReposito import com.crisiscleanup.core.data.repository.CrisisCleanupAppConfigRepository import com.crisiscleanup.core.data.repository.CrisisCleanupCasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupDataManagementRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupIncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.CrisisCleanupListsRepository import com.crisiscleanup.core.data.repository.CrisisCleanupLocalImageRepository import com.crisiscleanup.core.data.repository.CrisisCleanupOrgVolunteerRepository @@ -30,6 +31,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupWorkTypeStatusReposit import com.crisiscleanup.core.data.repository.CrisisCleanupWorksiteChangeRepository import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.IncidentCacheRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentWorksitesCacheRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -219,5 +221,10 @@ interface DataModule { ): IncidentCacheRepository @Binds - fun appConfigRepository(repository: CrisisCleanupAppConfigRepository): AppConfigRepository + fun bindsAppConfigRepository(repository: CrisisCleanupAppConfigRepository): AppConfigRepository + + @Binds + fun bindsIncidentClaimThresholdRepository( + repository: CrisisCleanupIncidentClaimThresholdRepository, + ): IncidentClaimThresholdRepository } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt index da9ab348..fa161288 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt @@ -26,7 +26,7 @@ class AccountDataRefresher @Inject constructor( private val networkDataSource: CrisisCleanupNetworkDataSource, private val accountDataRepository: AccountDataRepository, private val organizationsRepository: OrganizationsRepository, - private val incidentsRepository: IncidentsRepository, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, private val accountEventBus: AccountEventBus, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, @@ -74,7 +74,7 @@ class AccountDataRefresher @Inject constructor( } null } - incidentsRepository.saveIncidentClaimThresholds( + incidentClaimThresholdRepository.saveIncidentClaimThresholds( accountId, incidentThresholds, ) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt index 6d4214ee..338e3257 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppConfigRepository.kt @@ -1,10 +1,14 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.datastore.AppConfigDataSource +import com.crisiscleanup.core.model.data.AppConfigData import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import kotlinx.coroutines.flow.Flow import javax.inject.Inject interface AppConfigRepository { + val appConfig: Flow + suspend fun pullAppConfig() } @@ -12,6 +16,8 @@ class CrisisCleanupAppConfigRepository @Inject constructor( private val networkDataSource: CrisisCleanupNetworkDataSource, private val appConfigDataSource: AppConfigDataSource, ) : AppConfigRepository { + override val appConfig = appConfigDataSource.appConfigData + override suspend fun pullAppConfig() { val thresholds = networkDataSource.getClaimThresholds() appConfigDataSource.setClaimThresholds( diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt new file mode 100644 index 00000000..ce01b66b --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt @@ -0,0 +1,87 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.database.dao.IncidentDao +import com.crisiscleanup.core.database.dao.IncidentDaoPlus +import com.crisiscleanup.core.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.IncidentClaimThreshold +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.mapNotNull +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +interface IncidentClaimThresholdRepository { + suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) + + fun onWorksiteCreated(worksiteId: Long) +} + +@Singleton +class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( + incidentDao: IncidentDao, + accountInfoDataSource: AccountInfoDataSource, + private val incidentDaoPlus: IncidentDaoPlus, + appConfigRepository: AppConfigRepository, + incidentSelector: IncidentSelector, + @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, +) : IncidentClaimThresholdRepository { + private val worksitesCreated = ConcurrentHashMap() + + private val incidentClaimThresholdConfig = appConfigRepository.appConfig + + private val currentIncidentClaimThresholds = combine( + accountInfoDataSource.accountData, + incidentSelector.incidentId, + ::Pair, + ) + .flatMapLatest { (accountData, incidentId) -> + val accountId = accountData.id + incidentDao.streamIncidentClaimThreshold( + accountId = accountId, + incidentId = incidentId, + ) + .mapNotNull { it } + .map { + IncidentClaimThreshold( + incidentId = incidentId, + claimedCount = it.userClaimCount, + closedRatio = it.userCloseRatio, + ) + } + } + + private val isAtIncidentClaimThreshold = combine( + incidentClaimThresholdConfig, + currentIncidentClaimThresholds, + ::Pair, + ) + .mapLatest { (thresholdConfig, currentClaims) -> + currentClaims.claimedCount > thresholdConfig.claimCountThreshold && + currentClaims.closedRatio < thresholdConfig.closedClaimRatioThreshold + } + + override suspend fun saveIncidentClaimThresholds( + accountId: Long, + incidentThresholds: List, + ) { + try { + incidentDaoPlus.saveIncidentThresholds(accountId, incidentThresholds) + } catch (e: Exception) { + logger.logException(e) + } + } + + override fun onWorksiteCreated(worksiteId: Long) { + worksitesCreated.put(worksiteId, true) + } +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt index c2509d5e..3ffe987b 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt @@ -1,7 +1,6 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.model.data.Incident -import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.model.data.IncidentIdNameType import kotlinx.coroutines.flow.Flow import kotlinx.datetime.Instant @@ -38,9 +37,4 @@ interface IncidentsRepository { suspend fun pullIncidentOrganizations(incidentId: Long, force: Boolean = false) suspend fun getMatchingIncidents(q: String): List - - suspend fun saveIncidentClaimThresholds( - accountId: Long, - incidentThresholds: List, - ) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index be006382..9154537a 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -23,7 +23,6 @@ import com.crisiscleanup.core.datastore.LocalAppPreferencesDataSource import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.INCIDENT_ORGANIZATIONS_STABLE_MODEL_BUILD_VERSION import com.crisiscleanup.core.model.data.Incident -import com.crisiscleanup.core.model.data.IncidentClaimThreshold import com.crisiscleanup.core.model.data.IncidentIdNameType import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkIncident @@ -281,15 +280,4 @@ class OfflineFirstIncidentsRepository @Inject constructor( } override suspend fun getMatchingIncidents(q: String) = incidentDaoPlus.getMatchingIncidents(q) - - override suspend fun saveIncidentClaimThresholds( - accountId: Long, - incidentThresholds: List, - ) { - try { - incidentDaoPlus.saveIncidentThresholds(accountId, incidentThresholds) - } catch (e: Exception) { - logger.logException(e) - } - } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index 7e5473eb..08258e69 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -109,6 +109,18 @@ interface IncidentDao { @Upsert suspend fun upsertIncidentClaimThresholds(claimThresholds: List) + @Transaction + @Query( + """ + SELECT * FROM incident_claim_thresholds + WHERE user_id=:accountId AND incident_id=:incidentId + """, + ) + fun streamIncidentClaimThreshold( + accountId: Long, + incidentId: Long, + ): Flow + @Upsert suspend fun upsertFormFields(formFields: Collection) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 0983bb20..7dc07696 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -28,6 +28,7 @@ import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AppPreferencesRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -88,7 +89,7 @@ class CreateEditCaseViewModel @Inject constructor( incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, - worksitesRepository: WorksitesRepository, + private val worksitesRepository: WorksitesRepository, languageRepository: LanguageTranslationsRepository, languageRefresher: LanguageRefresher, workTypeStatusRepository: WorkTypeStatusRepository, @@ -96,6 +97,7 @@ class CreateEditCaseViewModel @Inject constructor( private val incidentSelector: IncidentSelector, private val translator: KeyResourceTranslator, private val worksiteChangeRepository: WorksiteChangeRepository, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, localImageRepository: LocalImageRepository, private val worksiteImageRepository: WorksiteImageRepository, private val preferencesRepository: AppPreferencesRepository, @@ -104,7 +106,6 @@ class CreateEditCaseViewModel @Inject constructor( packageManager: PackageManager, appEnv: AppEnv, @Logger(CrisisCleanupLoggers.Worksites) logger: AppLogger, - @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, inputValidator: InputValidator, searchWorksitesRepository: SearchWorksitesRepository, @@ -115,6 +116,8 @@ class CreateEditCaseViewModel @Inject constructor( locationProvider: LocationProvider, addressSearchRepository: AddressSearchRepository, drawableResourceBitmapProvider: DrawableResourceBitmapProvider, + + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @Dispatcher(Default) coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : EditCaseBaseViewModel(editableWorksiteProvider, translator, logger), CaseCameraMediaManager { private val caseEditorArgs = CaseEditorArgs(savedStateHandle) @@ -695,6 +698,14 @@ class CreateEditCaseViewModel @Inject constructor( syncPusher.appPushWorksite(worksiteId, true) + incidentClaimThresholdRepository.onWorksiteCreated(worksiteId) + + worksitesRepository.setRecentWorksite( + incidentId = updatedIncidentId, + worksiteId = worksiteId, + viewStart = Clock.System.now(), + ) + if (isIncidentChange) { changeExistingWorksite.value = ExistingWorksiteIdentifier(saveIncidentId, worksiteId) From a0f4f112523e2b6e79549985cda57c0036a9dfe9 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 22 Sep 2025 13:01:50 -0400 Subject: [PATCH 16/23] Claim unclaimed worktypes in edit Case with no other changes --- .../java/com/crisiscleanup/core/model/data/Worksite.kt | 9 +++++++++ .../feature/caseeditor/CreateEditCaseViewModel.kt | 5 ++++- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt index 41899a05..e280ce6f 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt @@ -87,6 +87,15 @@ data class Worksite( fun toggleHighPriorityFlag() = toggleFlag(WorksiteFlagType.HighPriority) + val unclaimedCount by lazy { + workTypes.fold( + 0, + { acc, workType -> + acc + if (workType.orgClaim == null) 1 else 0 + }, + ) + } + val isReleaseEligible = createdAt?.let { Clock.System.now().minus(it) > WorkTypeReleaseDaysThreshold } ?: false diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 7dc07696..b68293b2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -623,7 +623,10 @@ class CreateEditCaseViewModel @Inject constructor( val saveIncidentId = saveChangeIncident.id val isIncidentChange = saveIncidentId != EmptyIncident.id && saveIncidentId != worksite.incidentId - if (worksite == initialWorksite && !isIncidentChange) { + if (worksite == initialWorksite && + !isIncidentChange && + (!claimUnclaimed || worksite.unclaimedCount == 0) + ) { if (hasNewWorksitePhotosImages) { propertyEditor?.propertyInputData?.let { setInvalidSection(0, it) From aec25fa566eb16189ca3bf5a0bfcf85d2690f05c Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 22 Sep 2025 16:45:46 -0400 Subject: [PATCH 17/23] Analyze unsynced work changes by claimed and closed status --- .../core/data/WorkTypeAnalyzer.kt | 104 ++++++++++++++++++ .../crisiscleanup/core/data/di/DataModule.kt | 5 + .../IncidentClaimThresholdRepository.kt | 104 +++++++++++------- .../48.json | 15 ++- .../core/database/dao/IncidentDao.kt | 4 +- .../core/database/dao/WorksiteChangeDao.kt | 10 ++ .../database/model/WorksiteChangeEntity.kt | 1 + 7 files changed, 199 insertions(+), 44 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt new file mode 100644 index 00000000..44f5dd8e --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt @@ -0,0 +1,104 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.database.dao.WorksiteChangeDao +import com.crisiscleanup.core.model.data.closedWorkTypeStatuses +import com.crisiscleanup.core.model.data.statusFromLiteral +import com.crisiscleanup.core.network.worksitechange.WorkTypeSnapshot +import com.crisiscleanup.core.network.worksitechange.WorksiteChange +import kotlinx.serialization.json.Json +import javax.inject.Inject + +interface WorkTypeAnalyzer { + fun countUnsyncedClaimCloseWork( + orgId: Long, + incidentId: Long, + ignoreWorksiteIds: Set, + ): ClaimCloseCounts +} + +private val WorkTypeSnapshot.WorkType.isClosed: Boolean + get() { + val workTypeStatus = statusFromLiteral(status) + return closedWorkTypeStatuses.contains(workTypeStatus) + } + +class WorksiteChangeWorkTypeAnalyzer @Inject constructor( + private val worksiteChangeDao: WorksiteChangeDao, +) : WorkTypeAnalyzer { + override fun countUnsyncedClaimCloseWork( + orgId: Long, + incidentId: Long, + ignoreWorksiteIds: Set, + ): ClaimCloseCounts { + val worksiteChangesLookup = mutableMapOf>() + worksiteChangeDao.getOrgChanges(orgId) + .filter { + // TODO Write tests + !ignoreWorksiteIds.contains(it.entity.worksiteId) + } + .onEach { + // TODO Write tests + with(it.entity) { + val entry = worksiteChangesLookup[worksiteId] + worksiteChangesLookup[worksiteId] = if (entry == null) { + Pair(changeData, null) + } else { + Pair(entry.first, changeData) + } + } + } + + val workTypeChanges = + mutableListOf>() + + val json = Json { + ignoreUnknownKeys = true + explicitNulls = false + } + worksiteChangesLookup.forEach { entry -> + val (firstSerializedChange, lastSerializedChange) = entry.value + val firstChange = json.decodeFromString(firstSerializedChange) + firstChange.start?.let { firstSnapshot -> + if (firstSnapshot.core.networkId > 0) { + val lastSnapshot = lastSerializedChange?.let { + json.decodeFromString(lastSerializedChange).change + } ?: firstChange.change + if (lastSnapshot.core.incidentId == incidentId) { + val startWorkLookup = firstSnapshot.workTypes.associateBy { it.localId } + val lastWorkLookup = lastSnapshot.workTypes.associateBy { it.localId } + for ((id, startWorkType) in startWorkLookup) { + val lastWorkType = lastWorkLookup[id]?.workType + val change = Pair(startWorkType.workType, lastWorkType) + workTypeChanges.add(change) + } + } + } + } + } + + var claimCount = 0 + var closeCount = 0 + for ((startWorkType, lastWorkType) in workTypeChanges) { + val wasClaimed = startWorkType.orgClaim == orgId + val isClaimed = lastWorkType?.orgClaim == orgId + // TODO Write tests + if (wasClaimed != isClaimed) { + if (isClaimed) { + claimCount++ + } + + if (lastWorkType?.isClosed == true) { + closeCount++ + } + } + } + + return ClaimCloseCounts(claimCount = claimCount, closeCount = closeCount) + } +} + +data class ClaimCloseCounts( + val claimCount: Int, + val closeCount: Int, +) + diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt index 941919c2..8d58eacb 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt @@ -6,6 +6,8 @@ import com.crisiscleanup.core.data.AppIncidentMapTracker import com.crisiscleanup.core.data.IncidentMapTracker import com.crisiscleanup.core.data.IncidentSelectManager import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorkTypeAnalyzer +import com.crisiscleanup.core.data.WorksiteChangeWorkTypeAnalyzer import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AccountUpdateRepository import com.crisiscleanup.core.data.repository.AppConfigRepository @@ -227,4 +229,7 @@ interface DataModule { fun bindsIncidentClaimThresholdRepository( repository: CrisisCleanupIncidentClaimThresholdRepository, ): IncidentClaimThresholdRepository + + @Binds + fun bindsWorkTypeAnalyzer(analyzer: WorksiteChangeWorkTypeAnalyzer): WorkTypeAnalyzer } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt index ce01b66b..7fed1e21 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt @@ -3,19 +3,18 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.data.ClaimCloseCounts import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorksiteChangeWorkTypeAnalyzer import com.crisiscleanup.core.database.dao.IncidentDao import com.crisiscleanup.core.database.dao.IncidentDaoPlus import com.crisiscleanup.core.datastore.AccountInfoDataSource import com.crisiscleanup.core.model.data.IncidentClaimThreshold -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.first import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton +import kotlin.math.ceil interface IncidentClaimThresholdRepository { suspend fun saveIncidentClaimThresholds( @@ -24,51 +23,27 @@ interface IncidentClaimThresholdRepository { ) fun onWorksiteCreated(worksiteId: Long) + + suspend fun isUnderClaimThreshold(worksiteId: Long, additionalClaimCount: Int): Boolean } @Singleton class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( - incidentDao: IncidentDao, - accountInfoDataSource: AccountInfoDataSource, + private val incidentDao: IncidentDao, + private val accountInfoDataSource: AccountInfoDataSource, private val incidentDaoPlus: IncidentDaoPlus, + private val workTypeAnalyzer: WorksiteChangeWorkTypeAnalyzer, appConfigRepository: AppConfigRepository, - incidentSelector: IncidentSelector, + private val incidentSelector: IncidentSelector, @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, ) : IncidentClaimThresholdRepository { private val worksitesCreated = ConcurrentHashMap() private val incidentClaimThresholdConfig = appConfigRepository.appConfig - private val currentIncidentClaimThresholds = combine( - accountInfoDataSource.accountData, - incidentSelector.incidentId, - ::Pair, - ) - .flatMapLatest { (accountData, incidentId) -> - val accountId = accountData.id - incidentDao.streamIncidentClaimThreshold( - accountId = accountId, - incidentId = incidentId, - ) - .mapNotNull { it } - .map { - IncidentClaimThreshold( - incidentId = incidentId, - claimedCount = it.userClaimCount, - closedRatio = it.userCloseRatio, - ) - } - } - - private val isAtIncidentClaimThreshold = combine( - incidentClaimThresholdConfig, - currentIncidentClaimThresholds, - ::Pair, - ) - .mapLatest { (thresholdConfig, currentClaims) -> - currentClaims.claimedCount > thresholdConfig.claimCountThreshold && - currentClaims.closedRatio < thresholdConfig.closedClaimRatioThreshold - } + override fun onWorksiteCreated(worksiteId: Long) { + worksitesCreated.put(worksiteId, true) + } override suspend fun saveIncidentClaimThresholds( accountId: Long, @@ -81,7 +56,56 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( } } - override fun onWorksiteCreated(worksiteId: Long) { - worksitesCreated.put(worksiteId, true) + // TODO Write tests + override suspend fun isUnderClaimThreshold( + worksiteId: Long, + additionalClaimCount: Int, + ): Boolean { + if (additionalClaimCount <= 0) { + return true + } + + val incidentId = incidentSelector.incidentId.first() + + val accountData = accountInfoDataSource.accountData.first() + val accountId = accountData.id + + val thresholdConfig = incidentClaimThresholdConfig.first() + val claimCountThreshold = thresholdConfig.claimCountThreshold + val closeRatioThreshold = thresholdConfig.closedClaimRatioThreshold + + val currentIncidentThreshold = incidentDao.getIncidentClaimThreshold( + accountId = accountId, + incidentId = incidentId, + ) + val userClaimCount = currentIncidentThreshold?.userClaimCount ?: 0 + val userCloseRatio = currentIncidentThreshold?.userCloseRatio ?: 0.0f + + var unsyncedCounts = ClaimCloseCounts(0, 0) + if (!worksitesCreated.contains(worksiteId)) { + try { + val orgId = accountData.org.id + unsyncedCounts = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId = orgId, + incidentId = incidentId, + worksitesCreated.keys, + ) + } catch (e: Exception) { + logger.logException(e) + } + } + val unsyncedClaimCount = unsyncedCounts.claimCount + + val claimCount = userClaimCount + unsyncedClaimCount + val closeRatio = if (unsyncedClaimCount > 0) { + val userCloseCount = ceil(userCloseRatio * userClaimCount) + val closeCount = userCloseCount + unsyncedCounts.closeCount + closeCount / claimCount + } else { + userCloseRatio + } + + return claimCount < claimCountThreshold || + closeRatio >= closeRatioThreshold } } diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json index d839c409..56c81a4a 100644 --- a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/48.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 48, - "identityHash": "4686c49696fda2353872c0de154f8605", + "identityHash": "14cfa1c49672e462fb73a8dffab22516", "entities": [ { "tableName": "work_type_statuses", @@ -1536,6 +1536,17 @@ "DESC" ], "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt_at_created_at` ON `${TABLE_NAME}` (`worksite_id` ASC, `save_attempt_at` ASC, `created_at` DESC)" + }, + { + "name": "index_worksite_changes_organization_id_worksite_id_created_at", + "unique": false, + "columnNames": [ + "organization_id", + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_organization_id_worksite_id_created_at` ON `${TABLE_NAME}` (`organization_id`, `worksite_id`, `created_at`)" } ], "foreignKeys": [ @@ -3353,7 +3364,7 @@ ], "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, '4686c49696fda2353872c0de154f8605')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '14cfa1c49672e462fb73a8dffab22516')" ] } } \ No newline at end of file diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index 08258e69..f48d702c 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -116,10 +116,10 @@ interface IncidentDao { WHERE user_id=:accountId AND incident_id=:incidentId """, ) - fun streamIncidentClaimThreshold( + fun getIncidentClaimThreshold( accountId: Long, incidentId: Long, - ): Flow + ): IncidentClaimThresholdEntity? @Upsert suspend fun upsertFormFields(formFields: Collection) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt index 35fab675..16001d41 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteChangeDao.kt @@ -21,6 +21,16 @@ interface WorksiteChangeDao { @Query("SELECT * FROM worksite_changes WHERE worksite_id=:worksiteId ORDER BY created_at ASC") fun getOrdered(worksiteId: Long): List + @Transaction + @Query( + """ + SELECT * FROM worksite_changes + WHERE organization_id=:orgId + ORDER BY worksite_id, created_at + """, + ) + fun getOrgChanges(orgId: Long): List + @Transaction @Query( """ diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt index c70f8eb3..f9f50b78 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/WorksiteChangeEntity.kt @@ -26,6 +26,7 @@ import kotlinx.datetime.Instant value = ["worksite_id", "save_attempt_at", "created_at"], orders = [Order.ASC, Order.ASC, Order.DESC], ), + Index(value = ["organization_id", "worksite_id", "created_at"]), ], ) data class WorksiteChangeEntity( From 9c2962d121ea70d6c6f0547f757f2011d7007ede Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 23 Sep 2025 15:07:07 -0400 Subject: [PATCH 18/23] Test Incident claim threshold scenarios --- core/data/build.gradle.kts | 1 + .../core/data/WorkTypeAnalyzer.kt | 1 - .../IncidentClaimThresholdRepository.kt | 21 +- .../IncidentClaimThresholdRepositoryTest.kt | 221 ++++++++++++++++++ 4 files changed, 231 insertions(+), 13 deletions(-) create mode 100644 core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index 37cbbe32..747888ff 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -34,4 +34,5 @@ dependencies { testImplementation(projects.core.testing) testImplementation(projects.core.datastoreTest) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) } \ No newline at end of file diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt index 44f5dd8e..9a24a3e8 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt @@ -101,4 +101,3 @@ data class ClaimCloseCounts( val claimCount: Int, val closeCount: Int, ) - diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt index 7fed1e21..8f8f74ba 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt @@ -5,7 +5,7 @@ import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.data.ClaimCloseCounts import com.crisiscleanup.core.data.IncidentSelector -import com.crisiscleanup.core.data.WorksiteChangeWorkTypeAnalyzer +import com.crisiscleanup.core.data.WorkTypeAnalyzer import com.crisiscleanup.core.database.dao.IncidentDao import com.crisiscleanup.core.database.dao.IncidentDaoPlus import com.crisiscleanup.core.datastore.AccountInfoDataSource @@ -24,23 +24,21 @@ interface IncidentClaimThresholdRepository { fun onWorksiteCreated(worksiteId: Long) - suspend fun isUnderClaimThreshold(worksiteId: Long, additionalClaimCount: Int): Boolean + suspend fun isWithinClaimCloseThreshold(worksiteId: Long, additionalClaimCount: Int): Boolean } @Singleton class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( private val incidentDao: IncidentDao, - private val accountInfoDataSource: AccountInfoDataSource, private val incidentDaoPlus: IncidentDaoPlus, - private val workTypeAnalyzer: WorksiteChangeWorkTypeAnalyzer, - appConfigRepository: AppConfigRepository, + private val accountInfoDataSource: AccountInfoDataSource, + private val workTypeAnalyzer: WorkTypeAnalyzer, + private val appConfigRepository: AppConfigRepository, private val incidentSelector: IncidentSelector, @Logger(CrisisCleanupLoggers.Incidents) private val logger: AppLogger, ) : IncidentClaimThresholdRepository { private val worksitesCreated = ConcurrentHashMap() - private val incidentClaimThresholdConfig = appConfigRepository.appConfig - override fun onWorksiteCreated(worksiteId: Long) { worksitesCreated.put(worksiteId, true) } @@ -56,8 +54,7 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( } } - // TODO Write tests - override suspend fun isUnderClaimThreshold( + override suspend fun isWithinClaimCloseThreshold( worksiteId: Long, additionalClaimCount: Int, ): Boolean { @@ -70,7 +67,7 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( val accountData = accountInfoDataSource.accountData.first() val accountId = accountData.id - val thresholdConfig = incidentClaimThresholdConfig.first() + val thresholdConfig = appConfigRepository.appConfig.first() val claimCountThreshold = thresholdConfig.claimCountThreshold val closeRatioThreshold = thresholdConfig.closedClaimRatioThreshold @@ -82,7 +79,7 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( val userCloseRatio = currentIncidentThreshold?.userCloseRatio ?: 0.0f var unsyncedCounts = ClaimCloseCounts(0, 0) - if (!worksitesCreated.contains(worksiteId)) { + if (!worksitesCreated.containsKey(worksiteId)) { try { val orgId = accountData.org.id unsyncedCounts = workTypeAnalyzer.countUnsyncedClaimCloseWork( @@ -106,6 +103,6 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( } return claimCount < claimCountThreshold || - closeRatio >= closeRatioThreshold + closeRatio >= closeRatioThreshold } } diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt new file mode 100644 index 00000000..15740855 --- /dev/null +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt @@ -0,0 +1,221 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.data.ClaimCloseCounts +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorkTypeAnalyzer +import com.crisiscleanup.core.database.dao.IncidentDao +import com.crisiscleanup.core.database.dao.IncidentDaoPlus +import com.crisiscleanup.core.database.model.IncidentClaimThresholdEntity +import com.crisiscleanup.core.datastore.AccountInfoDataSource +import com.crisiscleanup.core.model.data.AppConfigData +import com.crisiscleanup.core.model.data.OrgData +import com.crisiscleanup.core.model.data.emptyAccountData +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.just +import io.mockk.runs +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class IncidentClaimThresholdRepositoryTest { + @MockK + private lateinit var incidentDao: IncidentDao + + @MockK + private lateinit var incidentDaoPlus: IncidentDaoPlus + + @MockK + private lateinit var accountInfoDataSource: AccountInfoDataSource + + @MockK + private lateinit var workTypeAnalyzer: WorkTypeAnalyzer + + @MockK + private lateinit var appConfigRepository: AppConfigRepository + + @MockK + private lateinit var incidentSelector: IncidentSelector + + @MockK + private lateinit var logger: AppLogger + private val slotException = slot() + + private lateinit var claimThresholdRepository: IncidentClaimThresholdRepository + + @Before + fun setUp() { + MockKAnnotations.init(this) + + every { incidentSelector.incidentId } returns MutableStateFlow(34) + every { + accountInfoDataSource.accountData + } returns flowOf( + emptyAccountData.copy( + id = 84, + org = OrgData(77, "org"), + ), + ) + + every { logger.logException(capture(slotException)) } just runs + + claimThresholdRepository = CrisisCleanupIncidentClaimThresholdRepository( + incidentDao, + incidentDaoPlus, + accountInfoDataSource, + workTypeAnalyzer, + appConfigRepository, + incidentSelector, + logger, + ) + } + + private fun makeClaimThresholdEntity( + claimCount: Int = 0, + closeRatio: Float = 0f, + accountId: Long = 84, + incidentId: Long = 34, + ) = IncidentClaimThresholdEntity( + userId = accountId, + incidentId = incidentId, + userClaimCount = claimCount, + userCloseRatio = closeRatio, + ) + + @Test + fun nonPositiveClaimCount() = runTest { + for (i in -1..0) { + val isUnderClaimThreshold = claimThresholdRepository.isWithinClaimCloseThreshold(1, i) + assertTrue(isUnderClaimThreshold) + verify(exactly = 0) { + incidentDao.getIncidentClaimThreshold(any(), any()) + } + } + } + + @Test + fun skipUnsynced() = runTest { + claimThresholdRepository.onWorksiteCreated(354) + + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(10, 0.5f), + ) + + var dbCallCounter = 0 + val dbResults = listOf( + makeClaimThresholdEntity(0, 0f), + makeClaimThresholdEntity(10, 0.1f), + makeClaimThresholdEntity(11, 0.1f), + makeClaimThresholdEntity(9, 0.5f), + makeClaimThresholdEntity(9, 0.5001f), + makeClaimThresholdEntity(10, 0.5f), + makeClaimThresholdEntity(11, 0.5001f), + null, + ) + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } answers { + dbResults[dbCallCounter++] + } + + val expectedUnder = listOf( + true, + false, + false, + true, + true, + true, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + + verify(exactly = 0) { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + any(), + any(), + any(), + ) + } + } + + @Test + fun unsyncedCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(10, 0.5f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(9, 4), + ClaimCloseCounts(10, 4), + ClaimCloseCounts(11, 5), + ClaimCloseCounts(9, 5), + ClaimCloseCounts(9, 6), + ClaimCloseCounts(10, 5), + ClaimCloseCounts(11, 6), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + true, + false, + false, + true, + true, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + + @Test + fun analyzerException() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(19, 0.49999f) + + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } throws (Exception("test-exception")) + + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(true, actual) + + assertEquals("test-exception", slotException.captured.message) + } +} From feb6eb7ff9b017e0771108e08e8a2e5bce789c9e Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 23 Sep 2025 17:43:36 -0400 Subject: [PATCH 19/23] Test unsynced work analyzer --- .../core/data/WorkTypeAnalyzer.kt | 16 +- .../IncidentClaimThresholdRepository.kt | 2 +- .../core/data/WorkTypeAnalyzerTest.kt | 412 ++++++++++++++++++ .../IncidentClaimThresholdRepositoryTest.kt | 78 ++++ 4 files changed, 500 insertions(+), 8 deletions(-) create mode 100644 core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt index 9a24a3e8..fc8d7902 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/WorkTypeAnalyzer.kt @@ -33,11 +33,9 @@ class WorksiteChangeWorkTypeAnalyzer @Inject constructor( val worksiteChangesLookup = mutableMapOf>() worksiteChangeDao.getOrgChanges(orgId) .filter { - // TODO Write tests !ignoreWorksiteIds.contains(it.entity.worksiteId) } .onEach { - // TODO Write tests with(it.entity) { val entry = worksiteChangesLookup[worksiteId] worksiteChangesLookup[worksiteId] = if (entry == null) { @@ -67,6 +65,7 @@ class WorksiteChangeWorkTypeAnalyzer @Inject constructor( val startWorkLookup = firstSnapshot.workTypes.associateBy { it.localId } val lastWorkLookup = lastSnapshot.workTypes.associateBy { it.localId } for ((id, startWorkType) in startWorkLookup) { + // TODO Test coverage on last work type is null val lastWorkType = lastWorkLookup[id]?.workType val change = Pair(startWorkType.workType, lastWorkType) workTypeChanges.add(change) @@ -81,15 +80,18 @@ class WorksiteChangeWorkTypeAnalyzer @Inject constructor( for ((startWorkType, lastWorkType) in workTypeChanges) { val wasClaimed = startWorkType.orgClaim == orgId val isClaimed = lastWorkType?.orgClaim == orgId - // TODO Write tests if (wasClaimed != isClaimed) { - if (isClaimed) { - claimCount++ - } + claimCount += if (isClaimed) 1 else -1 - if (lastWorkType?.isClosed == true) { + if (isClaimed && lastWorkType.isClosed) { closeCount++ } + } else if (isClaimed) { + val wasClosed = startWorkType.isClosed + val isClosed = lastWorkType.isClosed + if (wasClosed != isClosed) { + closeCount += if (isClosed) 1 else -1 + } } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt index 8f8f74ba..98f5145a 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepository.kt @@ -94,7 +94,7 @@ class CrisisCleanupIncidentClaimThresholdRepository @Inject constructor( val unsyncedClaimCount = unsyncedCounts.claimCount val claimCount = userClaimCount + unsyncedClaimCount - val closeRatio = if (unsyncedClaimCount > 0) { + val closeRatio = if (claimCount > 0) { val userCloseCount = ceil(userCloseRatio * userClaimCount) val closeCount = userCloseCount + unsyncedCounts.closeCount closeCount / claimCount diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt new file mode 100644 index 00000000..dd943e37 --- /dev/null +++ b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt @@ -0,0 +1,412 @@ +package com.crisiscleanup.core.data + +import com.crisiscleanup.core.database.dao.WorksiteChangeDao +import com.crisiscleanup.core.database.model.PopulatedWorksiteChange +import com.crisiscleanup.core.database.model.WorksiteChangeEntity +import com.crisiscleanup.core.model.data.WorkTypeStatus +import com.crisiscleanup.core.network.worksitechange.CoreSnapshot +import com.crisiscleanup.core.network.worksitechange.WorkTypeSnapshot +import com.crisiscleanup.core.network.worksitechange.WorksiteChange +import com.crisiscleanup.core.network.worksitechange.WorksiteSnapshot +import io.mockk.MockKAnnotations +import io.mockk.every +import io.mockk.impl.annotations.MockK +import kotlinx.coroutines.test.runTest +import kotlinx.datetime.Clock +import kotlinx.serialization.json.Json +import org.junit.Before +import kotlin.test.Test +import kotlin.test.assertEquals + +class WorkTypeAnalyzerTest { + @MockK + private lateinit var worksiteChangeDao: WorksiteChangeDao + + private lateinit var workTypeAnalyzer: WorkTypeAnalyzer + + private val json = Json {} + + @Before + fun setUp() { + MockKAnnotations.init(this) + + workTypeAnalyzer = WorksiteChangeWorkTypeAnalyzer(worksiteChangeDao) + } + + private fun makeWorksiteChange( + snapshotStart: WorksiteSnapshot?, + snapshotChange: WorksiteSnapshot, + worksiteId: Long, + ) = PopulatedWorksiteChange( + WorksiteChangeEntity( + id = 0, + appVersion = 0, + organizationId = 0, + worksiteId = worksiteId, + syncUuid = "", + changeModelVersion = 0, + changeData = json.encodeToString( + WorksiteChange( + start = snapshotStart, + change = snapshotChange, + ), + ), + createdAt = Clock.System.now(), + saveAttempt = 0, + archiveAction = "", + ), + ) + + private fun worksiteChange10( + incidentId: Long = 152, + worksiteId: Long = 342, + orgId: Long = 52, + networkWorksiteId: Long = 40, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1, WorkTypeStatus.ClosedOutOfScope, orgId), + ), + ), + worksiteId, + ) + + private fun worksiteChangeN10( + incidentId: Long = 152, + worksiteId: Long = 343, + orgId: Long = 52, + networkWorksiteId: Long = 41, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1, orgId = orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1), + ), + ), + worksiteId, + ) + + private fun worksiteChange21( + incidentId: Long = 152, + worksiteId: Long = 344, + orgId: Long = 52, + networkWorksiteId: Long = 42, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedOutOfScope), + makeWorkTypeSnapshot(3), + makeWorkTypeSnapshot(4), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedRejected), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedDuplicate, orgId), + makeWorkTypeSnapshot(4, orgId = orgId), + ), + ), + worksiteId, + ) + + private fun worksiteChangeN01( + incidentId: Long = 152, + worksiteId: Long = 345, + orgId: Long = 52, + networkWorksiteId: Long = 43, + ) = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + networkWorksiteId = networkWorksiteId, + workTypes = listOf( + makeWorkTypeSnapshot(1, WorkTypeStatus.ClosedCompleted, orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(1, orgId = orgId), + ), + ), + worksiteId, + ) + + @Test + fun separateChanges() = runTest { + var dbChangeCounter = 0 + val dbChanges = listOf( + worksiteChange10(), + worksiteChangeN10(), + worksiteChange21(), + worksiteChangeN01(), + ) + every { worksiteChangeDao.getOrgChanges(52) } answers { + listOf(dbChanges[dbChangeCounter++]) + } + + val expectedCounts = listOf( + ClaimCloseCounts(1, 1), + ClaimCloseCounts(-1, 0), + ClaimCloseCounts(2, 1), + ClaimCloseCounts(0, -1), + ) + for (expected in expectedCounts) { + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + } + + @Test + fun combinedChanges() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(), + worksiteChangeN10(), + worksiteChange21(), + ) + + val expected = ClaimCloseCounts(2, 2) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + + @Test + fun localWorksiteChanges() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(worksiteId = 11), + worksiteChangeN10(worksiteId = 110), + worksiteChange21(worksiteId = 21), + ) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + setOf(11, 21, 110), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun noNetworkWorksiteId() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(networkWorksiteId = 0), + worksiteChangeN10(networkWorksiteId = 0), + worksiteChange21(networkWorksiteId = 0), + ) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun differentIncident() = runTest { + every { worksiteChangeDao.getOrgChanges(52) } returns listOf( + worksiteChange10(incidentId = 52), + worksiteChangeN10(), + worksiteChange21(), + ) + + val expected = ClaimCloseCounts(1, 1) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + 52, + 152, + emptySet(), + ) + assertEquals(expected, actual) + } + + @Test + fun noDistinguishingClaimChanges() = runTest { + val orgId = 42L + val incidentId = 152L + val worksiteChange = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.OpenUnresponsive), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedIncomplete, orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.NeedUnfilled), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedDoneByOthers, orgId), + ), + ), + 152, + ) + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf(worksiteChange) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun noDistinguishingCloseChange() = runTest { + val orgId = 42L + val incidentId = 152L + val worksiteChange = makeWorksiteChange( + snapshotStart = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + makeWorkTypeSnapshot(2, WorkTypeStatus.ClosedOutOfScope), + makeWorkTypeSnapshot(3, WorkTypeStatus.ClosedIncomplete), + makeWorkTypeSnapshot(4, orgId = orgId), + ), + ), + snapshotChange = makeWorksiteSnapshot( + incidentId = incidentId, + workTypes = listOf( + // No change in claim + makeWorkTypeSnapshot(2, WorkTypeStatus.OpenUnresponsive), + // Claimed not closed + makeWorkTypeSnapshot(3, WorkTypeStatus.OpenAssigned, orgId), + // Unclaimed closed + makeWorkTypeSnapshot(4, WorkTypeStatus.ClosedDoneByOthers), + ), + ), + 152, + ) + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf(worksiteChange) + + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + + assertEquals(ClaimCloseCounts(0, 0), actual) + } + + @Test + fun multipleCommits() = runTest { + val orgId = 42L + val worksiteId = 88L + val incidentId = 152L + + every { worksiteChangeDao.getOrgChanges(orgId) } returns listOf( + worksiteChangeN10(worksiteId = worksiteId), + worksiteChange10(worksiteId = worksiteId), + worksiteChange21(worksiteId = worksiteId), + worksiteChangeN01(worksiteId = worksiteId) + ) + + val expected = ClaimCloseCounts(0, 0) + val actual = workTypeAnalyzer.countUnsyncedClaimCloseWork( + orgId, + incidentId, + emptySet(), + ) + assertEquals(expected, actual) + } +} + +private fun makeWorksiteSnapshot( + incidentId: Long = 152, + networkWorksiteId: Long = 6252, + workTypes: List = emptyList(), +) = WorksiteSnapshot( + makeCoreSnapshot( + incidentId = incidentId, + networkId = networkWorksiteId, + ), + emptyList(), + emptyList(), + workTypes, +) + +private fun makeCoreSnapshot( + incidentId: Long = 152, + networkId: Long = 6252, +) = emptyCoreSnapshot.copy( + incidentId = incidentId, + networkId = networkId, +) + +private val emptyCoreSnapshot = CoreSnapshot( + id = 0, + address = "", + autoContactFrequencyT = "", + caseNumber = "", + city = "", + county = "", + createdAt = null, + email = null, + favoriteId = null, + formData = emptyMap(), + incidentId = 0, + keyWorkTypeId = null, + latitude = 0.0, + longitude = 0.0, + name = "", + networkId = 0, + phone1 = "", + phone2 = "", + postalCode = "", + reportedBy = null, + state = "", + svi = null, + updatedAt = null, + isAssignedToOrgMember = false, +) + +private fun makeWorkTypeSnapshot( + localId: Long, + status: WorkTypeStatus = WorkTypeStatus.OpenUnassigned, + orgId: Long? = null, +) = emptyWorkTypeSnapshot.copy( + localId = localId, + workType = emptyWorkTypeSnapshot.workType.copy( + orgClaim = orgId, + status = status.literal, + ), +) + +private val emptyWorkTypeSnapshot = WorkTypeSnapshot( + localId = 0, + workType = WorkTypeSnapshot.WorkType( + id = 0, + orgClaim = null, + status = "", + workType = "", + ), +) \ No newline at end of file diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt index 15740855..189a9ec2 100644 --- a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt @@ -195,6 +195,84 @@ class IncidentClaimThresholdRepositoryTest { } } + @Test + fun unsyncedNegativeClaimCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(30, 0.3f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(-9, 1), + ClaimCloseCounts(-10, 0), + ClaimCloseCounts(-10, 1), + ClaimCloseCounts(-11, 0), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + false, + false, + true, + true, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + + @Test + fun unsyncedNegativeCloseCounts() = runTest { + every { appConfigRepository.appConfig } returns flowOf( + AppConfigData(20, 0.5f), + ) + + every { + incidentDao.getIncidentClaimThreshold(accountId = 84, incidentId = 34) + } returns makeClaimThresholdEntity(16, 0.75f) + + var analyzerCallCounter = 0 + val analyzerResults = listOf( + ClaimCloseCounts(4, -2), + ClaimCloseCounts(4, -1), + ClaimCloseCounts(4, -2), + ClaimCloseCounts(4, -3), + ) + every { + workTypeAnalyzer.countUnsyncedClaimCloseWork( + 77, + 34, + emptySet(), + ) + } answers { + analyzerResults[analyzerCallCounter++] + } + + val expectedUnder = listOf( + true, + true, + true, + false, + ) + for (i in expectedUnder.indices) { + val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) + assertEquals(expectedUnder[i], actual, "$i") + } + } + @Test fun analyzerException() = runTest { every { appConfigRepository.appConfig } returns flowOf( From 8431b7643f3993b0718c76c7972bd3bff0e778e4 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 24 Sep 2025 14:20:40 -0400 Subject: [PATCH 20/23] Alert and disallow over claiming Cases in view Case screen --- .../data/repository/AccountDataRefresher.kt | 21 ++++++++++---- .../core/data/WorkTypeAnalyzerTest.kt | 4 +-- .../crisiscleanup/core/model/data/Worksite.kt | 9 ++++++ .../caseeditor/CaseEditorDataLoader.kt | 10 +++++++ .../caseeditor/CreateEditCaseViewModel.kt | 7 ++++- .../feature/caseeditor/ViewCaseViewModel.kt | 29 +++++++++++++++++++ .../caseeditor/ui/OverClaimAlertDialog.kt | 26 +++++++++++++++++ .../feature/caseeditor/ui/ViewCaseScreen.kt | 5 ++++ 8 files changed, 103 insertions(+), 8 deletions(-) create mode 100644 feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt index fa161288..16af9b16 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRefresher.kt @@ -19,6 +19,7 @@ import javax.inject.Inject import javax.inject.Singleton import kotlin.time.Duration import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.minutes @Singleton class AccountDataRefresher @Inject constructor( @@ -32,6 +33,7 @@ class AccountDataRefresher @Inject constructor( @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, ) { private var accountDataUpdateTime = Instant.fromEpochSeconds(0) + private var incidentClaimThresholdTime = Instant.fromEpochSeconds(0) private suspend fun refreshAccountData( syncTag: String, @@ -45,7 +47,7 @@ class AccountDataRefresher @Inject constructor( return } - logger.logCapture("Syncing $syncTag") + logger.logCapture("Refreshing $syncTag") val accountId = accountDataRepository.accountData.first().id try { @@ -80,7 +82,9 @@ class AccountDataRefresher @Inject constructor( ) } - accountDataUpdateTime = Clock.System.now() + val now = Clock.System.now() + accountDataUpdateTime = now + incidentClaimThresholdTime = now } } catch (e: Exception) { logger.logException(e) @@ -88,7 +92,7 @@ class AccountDataRefresher @Inject constructor( } suspend fun updateProfilePicture() { - refreshAccountData("profile pic", false) + refreshAccountData("profile-pic", false) } suspend fun updateMyOrganization(force: Boolean) = withContext(ioDispatcher) { @@ -99,11 +103,18 @@ class AccountDataRefresher @Inject constructor( } suspend fun updateAcceptedTerms() { - refreshAccountData("accept terms", true) + refreshAccountData("accept-terms", true) } // Approved Incidents and Incident thresholds suspend fun updateProfileIncidentsData(force: Boolean = false) { - refreshAccountData("profile incidents data", force) + refreshAccountData("profile-incidents-data", force) + } + + suspend fun updateIncidentClaimThreshold() { + refreshAccountData( + "incident-claim-threshold", + Clock.System.now().minus(incidentClaimThresholdTime) > 5.minutes, + ) } } diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt index dd943e37..189a4827 100644 --- a/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt +++ b/core/data/src/test/java/com/crisiscleanup/core/data/WorkTypeAnalyzerTest.kt @@ -327,7 +327,7 @@ class WorkTypeAnalyzerTest { worksiteChangeN10(worksiteId = worksiteId), worksiteChange10(worksiteId = worksiteId), worksiteChange21(worksiteId = worksiteId), - worksiteChangeN01(worksiteId = worksiteId) + worksiteChangeN01(worksiteId = worksiteId), ) val expected = ClaimCloseCounts(0, 0) @@ -409,4 +409,4 @@ private val emptyWorkTypeSnapshot = WorkTypeSnapshot( status = "", workType = "", ), -) \ No newline at end of file +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt index e280ce6f..4dfb21ca 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Worksite.kt @@ -96,6 +96,15 @@ data class Worksite( ) } + fun getClaimedCount(orgId: Long): Int { + return workTypes.fold( + 0, + { acc, workType -> + acc + if (workType.orgClaim == orgId) 1 else 0 + }, + ) + } + val isReleaseEligible = createdAt?.let { Clock.System.now().minus(it) > WorkTypeReleaseDaysThreshold } ?: false diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt index 6ec55425..f71e32da 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt @@ -7,6 +7,7 @@ import com.crisiscleanup.core.common.log.TagLogger import com.crisiscleanup.core.commoncase.model.FormFieldNode import com.crisiscleanup.core.commoncase.model.WORK_FORM_GROUP_KEY import com.crisiscleanup.core.commoncase.model.flatten +import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository @@ -43,6 +44,7 @@ internal class CaseEditorDataLoader( incidentIdIn: Long, worksiteIdIn: Long?, accountDataRepository: AccountDataRepository, + accountDataRefresher: AccountDataRefresher, incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, @@ -375,6 +377,14 @@ internal class CaseEditorDataLoader( } } + coroutineScope.launch(coroutineDispatcher) { + try { + accountDataRefresher.updateIncidentClaimThreshold() + } catch (e: Exception) { + logger.logException(e) + } + } + coroutineScope.launch(coroutineDispatcher) { try { languageRefresher.pullLanguages() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index b68293b2..5623f116 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -26,6 +26,7 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.data.IncidentSelector import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone +import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AppPreferencesRepository import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository @@ -86,6 +87,7 @@ import kotlin.time.Duration.Companion.seconds class CreateEditCaseViewModel @Inject constructor( savedStateHandle: SavedStateHandle, accountDataRepository: AccountDataRepository, + accountDataRefresher: AccountDataRefresher, incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, @@ -240,6 +242,7 @@ class CreateEditCaseViewModel @Inject constructor( incidentIdIn, worksiteIdArg, accountDataRepository, + accountDataRefresher, incidentsRepository, incidentRefresher, incidentBoundsProvider, @@ -701,7 +704,9 @@ class CreateEditCaseViewModel @Inject constructor( syncPusher.appPushWorksite(worksiteId, true) - incidentClaimThresholdRepository.onWorksiteCreated(worksiteId) + if (isCreateWorksite) { + incidentClaimThresholdRepository.onWorksiteCreated(worksiteId) + } worksitesRepository.setRecentWorksite( incidentId = updatedIncidentId, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index 8a9bd254..44666c6b 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -31,6 +31,7 @@ import com.crisiscleanup.core.commoncase.oneDecimalFormat import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.AppPreferencesRepository +import com.crisiscleanup.core.data.repository.IncidentClaimThresholdRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -98,6 +99,7 @@ class ViewCaseViewModel @Inject constructor( val transferWorkTypeProvider: TransferWorkTypeProvider, permissionManager: PermissionManager, private val translator: KeyResourceTranslator, + private val incidentClaimThresholdRepository: IncidentClaimThresholdRepository, private val worksiteChangeRepository: WorksiteChangeRepository, private val worksiteImageRepository: WorksiteImageRepository, private val syncPusher: SyncPusher, @@ -225,6 +227,8 @@ class ViewCaseViewModel @Inject constructor( val actionDescriptionMessage = MutableStateFlow("") + var isOverClaimingWork by mutableStateOf(false) + init { updateHeaderTitle() @@ -235,6 +239,7 @@ class ViewCaseViewModel @Inject constructor( incidentIdArg, worksiteIdArg, accountDataRepository, + accountDataRefresher, incidentsRepository, incidentRefresher, incidentBoundsProvider, @@ -573,6 +578,25 @@ class ViewCaseViewModel @Inject constructor( } } + private suspend fun isOverClaiming( + startingWorksite: Worksite, + changedWorksite: Worksite, + ): Boolean { + organizationId?.let { orgId -> + val endClaimCount = changedWorksite.getClaimedCount(orgId) + val startClaimCount = startingWorksite.getClaimedCount(orgId) + val deltaClaimCount = endClaimCount - startClaimCount + if (deltaClaimCount > 0) { + return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( + changedWorksite.id, + deltaClaimCount, + ) + } + } + + return false + } + private val viewStateCaseData: CaseEditorViewState.CaseData? get() = viewState.value.asCaseData() private val organizationId: Long? @@ -593,6 +617,11 @@ class ViewCaseViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { isSavingWorksite.value = true try { + if (isOverClaiming(startingWorksite, changedWorksite)) { + isOverClaimingWork = true + return@launch + } + worksiteChangeRepository.saveWorksiteChange( startingWorksite, changedWorksite, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt new file mode 100644 index 00000000..0451e897 --- /dev/null +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.feature.caseeditor.ui + +import androidx.compose.runtime.Composable +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog +import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton +import com.crisiscleanup.core.designsystem.component.LinkifyHtmlText + +@Composable +fun OverClaimAlertDialog( + closeDialog: () -> Unit, +) { + val t = LocalAppTranslator.current + CrisisCleanupAlertDialog( + onDismissRequest = closeDialog, + title = t("~~Overload"), + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.ok"), + onClick = closeDialog, + ) + }, + ) { + LinkifyHtmlText(t("claiming_restricted_threshold_exceeded")) + } +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt index 2c9028c7..a0aff002 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt @@ -246,6 +246,11 @@ internal fun EditExistingCaseRoute( val actionDescription by viewModel.actionDescriptionMessage.collectAsStateWithLifecycle() TemporaryDialog(actionDescription) + + if (viewModel.isOverClaimingWork) { + val closeDialog = remember(viewModel) { { viewModel.isOverClaimingWork = false } } + OverClaimAlertDialog(closeDialog) + } } } From 3478cdb209e11687b2c4ac146aa128ce41f2e978 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 24 Sep 2025 15:07:40 -0400 Subject: [PATCH 21/23] Disallow and alert over claiming Cases in create/edit Case --- .../caseeditor/CreateEditCaseViewModel.kt | 46 ++++++++++++++++++- .../feature/caseeditor/ViewCaseViewModel.kt | 23 ++++------ .../caseeditor/ui/CreateEditCaseScreen.kt | 5 ++ 3 files changed, 58 insertions(+), 16 deletions(-) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 5623f116..87ab8f9b 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -69,6 +69,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map @@ -86,7 +87,7 @@ import kotlin.time.Duration.Companion.seconds @HiltViewModel class CreateEditCaseViewModel @Inject constructor( savedStateHandle: SavedStateHandle, - accountDataRepository: AccountDataRepository, + private val accountDataRepository: AccountDataRepository, accountDataRefresher: AccountDataRefresher, incidentsRepository: IncidentsRepository, incidentRefresher: IncidentRefresher, @@ -122,6 +123,26 @@ class CreateEditCaseViewModel @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, @Dispatcher(Default) coroutineDispatcher: CoroutineDispatcher = Dispatchers.Default, ) : EditCaseBaseViewModel(editableWorksiteProvider, translator, logger), CaseCameraMediaManager { + companion object { + internal suspend fun isOverClaiming( + orgId: Long, + startingWorksite: Worksite, + updatedWorksite: Worksite, + incidentClaimThresholdRepository: IncidentClaimThresholdRepository, + ): Boolean { + val endClaimCount = updatedWorksite.getClaimedCount(orgId) + val startClaimCount = startingWorksite.getClaimedCount(orgId) + val deltaClaimCount = endClaimCount - startClaimCount + if (deltaClaimCount > 0) { + return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( + updatedWorksite.id, + deltaClaimCount, + ) + } + return false + } + } + private val caseEditorArgs = CaseEditorArgs(savedStateHandle) private var worksiteIdArg = caseEditorArgs.worksiteId val isCreateWorksite: Boolean @@ -223,6 +244,8 @@ class CreateEditCaseViewModel @Inject constructor( override val capturePhotoUri: Uri? get() = worksiteImageRepository.newPhotoUri + var isOverClaimingWork by mutableStateOf(false) + init { updateHeaderTitle() @@ -604,6 +627,20 @@ class CreateEditCaseViewModel @Inject constructor( } } + private suspend fun isOverClaiming( + startingWorksite: Worksite, + updatedWorksite: Worksite, + ): Boolean { + val accountData = accountDataRepository.accountData.first() + val orgId = accountData.org.id + return isOverClaiming( + orgId, + startingWorksite, + updatedWorksite, + incidentClaimThresholdRepository, + ) + } + fun saveChanges( claimUnclaimed: Boolean, backOnSuccess: Boolean = true, @@ -682,6 +719,13 @@ class CreateEditCaseViewModel @Inject constructor( what3Words = updatedWhat3Words, ) + if (!isCreateWorksite && + isOverClaiming(worksite, updatedWorksite) + ) { + isOverClaimingWork = true + return@launch + } + worksiteIdArg = worksiteChangeRepository.saveWorksiteChange( initialWorksite, updatedWorksite, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index 44666c6b..7ed6f752 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -581,21 +581,14 @@ class ViewCaseViewModel @Inject constructor( private suspend fun isOverClaiming( startingWorksite: Worksite, changedWorksite: Worksite, - ): Boolean { - organizationId?.let { orgId -> - val endClaimCount = changedWorksite.getClaimedCount(orgId) - val startClaimCount = startingWorksite.getClaimedCount(orgId) - val deltaClaimCount = endClaimCount - startClaimCount - if (deltaClaimCount > 0) { - return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( - changedWorksite.id, - deltaClaimCount, - ) - } - } - - return false - } + ) = organizationId?.let { orgId -> + return@let CreateEditCaseViewModel.isOverClaiming( + orgId, + startingWorksite, + changedWorksite, + incidentClaimThresholdRepository, + ) + } ?: false private val viewStateCaseData: CaseEditorViewState.CaseData? get() = viewState.value.asCaseData() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt index 13dfadae..896b0fea 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CreateEditCaseScreen.kt @@ -111,6 +111,11 @@ internal fun CreateEditCaseRoute( openPhoto = openPhoto, ) } + + if (viewModel.isOverClaimingWork) { + val closeDialog = remember(viewModel) { { viewModel.isOverClaimingWork = false } } + OverClaimAlertDialog(closeDialog) + } } } } From ed854378f1e09981612dc3db3f938dc6b0150998 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 24 Sep 2025 15:33:53 -0400 Subject: [PATCH 22/23] Implement interface in sanbox app --- .../main/java/com/crisiscleanup/sandbox/SandboxApplication.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt index 1b684171..a331892c 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt @@ -130,6 +130,10 @@ class AppSyncer @Inject constructor() : SyncPuller, SyncPusher { override suspend fun syncPullStatuses() = SyncResult.NotAttempted("") + override fun appPullAppConfig() {} + + override suspend fun syncPullAppConfig() = SyncResult.NotAttempted("") + override fun appPushWorksite(worksiteId: Long, scheduleMediaSync: Boolean) {} override suspend fun syncPushWorksitesAsync() = CompletableDeferred(SyncResult.NotAttempted("")) From 51199310136daef88e7a5a76eb1bf7d3474d0326 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 1 Oct 2025 15:55:48 -0400 Subject: [PATCH 23/23] Update from cross development --- app/build.gradle.kts | 2 +- .../IncidentClaimThresholdRepositoryTest.kt | 1 - .../crisiscleanup/core/database/dao/IncidentDao.kt | 4 ++-- .../feature/caseeditor/CreateEditCaseViewModel.kt | 11 ++++------- .../feature/caseeditor/ui/OverClaimAlertDialog.kt | 4 ++-- 5 files changed, 9 insertions(+), 13 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b016f159..53714dfc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 280 + val buildVersion = 281 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt index 189a9ec2..a2d52ac3 100644 --- a/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/IncidentClaimThresholdRepositoryTest.kt @@ -134,7 +134,6 @@ class IncidentClaimThresholdRepositoryTest { true, true, true, - true, ) for (i in expectedUnder.indices) { val actual = claimThresholdRepository.isWithinClaimCloseThreshold(354, 1) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt index f48d702c..152c6701 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentDao.kt @@ -112,8 +112,8 @@ interface IncidentDao { @Transaction @Query( """ - SELECT * FROM incident_claim_thresholds - WHERE user_id=:accountId AND incident_id=:incidentId + SELECT * FROM incident_claim_thresholds + WHERE user_id=:accountId AND incident_id=:incidentId """, ) fun getIncidentClaimThreshold( diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt index 87ab8f9b..9d2ca237 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CreateEditCaseViewModel.kt @@ -133,13 +133,10 @@ class CreateEditCaseViewModel @Inject constructor( val endClaimCount = updatedWorksite.getClaimedCount(orgId) val startClaimCount = startingWorksite.getClaimedCount(orgId) val deltaClaimCount = endClaimCount - startClaimCount - if (deltaClaimCount > 0) { - return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( - updatedWorksite.id, - deltaClaimCount, - ) - } - return false + return !incidentClaimThresholdRepository.isWithinClaimCloseThreshold( + updatedWorksite.id, + deltaClaimCount, + ) } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt index 0451e897..83009365 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/OverClaimAlertDialog.kt @@ -13,7 +13,7 @@ fun OverClaimAlertDialog( val t = LocalAppTranslator.current CrisisCleanupAlertDialog( onDismissRequest = closeDialog, - title = t("~~Overload"), + title = t("info.claiming_restricted_threshold_exceeded_title"), confirmButton = { CrisisCleanupTextButton( text = t("actions.ok"), @@ -21,6 +21,6 @@ fun OverClaimAlertDialog( ) }, ) { - LinkifyHtmlText(t("claiming_restricted_threshold_exceeded")) + LinkifyHtmlText(t("info.claiming_restricted_threshold_exceeded")) } }