diff --git a/.gitignore b/.gitignore index 8a9ccabef..30d623fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -289,3 +289,9 @@ core/database/schemas/com.crisiscleanup.core.database.TestCrisisCleanupDatabase # Direnv !.envrc .envrc.local + +.crashlytics + +app/dependencies + +.kotlin \ No newline at end of file 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 8e2eeb8a8..69f3c79e8 100644 --- a/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt +++ b/app-sandbox/src/main/java/com/crisiscleanup/sandbox/SandboxApplication.kt @@ -69,6 +69,8 @@ class AppLogger @Inject constructor() : TagLogger { override fun logCapture(message: String) { Log.w(tag, message) } + + override fun setAccountId(id: String) {} } @Singleton diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fe220db1..58a2fe118 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,4 @@ +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension import com.google.samples.apps.nowinandroid.NiaBuildType plugins { @@ -9,11 +10,12 @@ plugins { id("jacoco") id("nowinandroid.android.application.firebase") id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") + id("com.google.firebase.crashlytics") } android { defaultConfig { - val buildVersion = 203 + val buildVersion = 211 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" @@ -52,6 +54,14 @@ android { debugSymbolLevel = "SYMBOL_TABLE" } + configure { + // Enable processing and uploading of native symbols to Firebase servers. + // By default, this is disabled to improve build speeds. + // This flag must be enabled to see properly-symbolicated native + // stack traces in the Crashlytics dashboard. + nativeSymbolUploadEnabled = true + } + // To publish on the Play store a private signing key is required, but to allow anyone // who clones the code to sign and run the release variant, use the debug signing key. // Uncomment to install locally. Change to build for Play store. @@ -115,6 +125,7 @@ dependencies { implementation(projects.feature.caseeditor) implementation(projects.feature.cases) implementation(projects.feature.dashboard) + implementation(projects.feature.lists) implementation(projects.feature.menu) implementation(projects.feature.mediamanage) implementation(projects.feature.organizationmanage) @@ -166,6 +177,9 @@ dependencies { // For Firebase support implementation(platform(libs.firebase.bom)) + // Crashlytics configuration + implementation(libs.firebase.crashlytics.ndk) + implementation(libs.firebase.analytics) implementation(libs.kotlinx.coroutines.playservices) implementation(libs.playservices.maps) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4d19d1c60..8231eee61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -27,4 +27,12 @@ # Android Studio Hedgehog require the following rules -dontwarn io.grpc.internal.DnsNameResolverProvider --dontwarn io.grpc.internal.PickFirstLoadBalancerProvider \ No newline at end of file +-dontwarn io.grpc.internal.PickFirstLoadBalancerProvider + +# Preserve information for crash reports +-keepattributes SourceFile,LineNumberTable # Keep file names and line numbers. +-renamesourcefileattribute SourceFile +-keep public class * extends java.lang.Exception # Optional: Keep custom exceptions. + +# For using androidxLifecycle 2.8.2 or encounter LocalLifecycleOwner not present Error +-keep class androidx.compose.ui.platform.AndroidCompositionLocals_androidKt { *; } diff --git a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt index 8c54dd056..c4649ef5f 100644 --- a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt +++ b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt @@ -6,6 +6,7 @@ import com.crisiscleanup.core.common.event.ExternalEventBus 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.common.queryParamMap import javax.inject.Inject class ExternalIntentProcessor @Inject constructor( @@ -46,6 +47,8 @@ class ExternalIntentProcessor @Inject constructor( if (code.isNotBlank()) { externalEventBus.onOrgUserInvite(code) } + } else if (urlPath.startsWith("/mobile_app_user_invite")) { + externalEventBus.onOrgPersistentInvite(url.queryParamMap) } else { return false } diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index f5a97586e..2a47c5932 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -191,13 +191,17 @@ class MainActivityViewModel @Inject constructor( init { accountDataRepository.accountData .onEach { - sync(false) - syncPuller.appPullIncident(incidentSelector.incidentId.first()) - accountDataRefresher.updateMyOrganization(true) - accountDataRefresher.updateApprovedIncidents() + if (it.areTokensValid) { + sync(false) + syncPuller.appPullIncident(incidentSelector.incidentId.first()) + accountDataRefresher.updateMyOrganization(true) + accountDataRefresher.updateApprovedIncidents() - if (!it.hasAcceptedTerms && !it.areTokensValid) { - authEventBus.onLogout() + logger.setAccountId(it.id.toString()) + } else { + if (!it.hasAcceptedTerms) { + authEventBus.onLogout() + } } } .launchIn(viewModelScope) diff --git a/app/src/main/java/com/crisiscleanup/log/CrisisCleanupAppLogger.kt b/app/src/main/java/com/crisiscleanup/log/CrisisCleanupAppLogger.kt index f5eefb2c3..2d63d214f 100644 --- a/app/src/main/java/com/crisiscleanup/log/CrisisCleanupAppLogger.kt +++ b/app/src/main/java/com/crisiscleanup/log/CrisisCleanupAppLogger.kt @@ -41,4 +41,8 @@ class CrisisCleanupAppLogger @Inject constructor( crashlytics.log(message) } } + + override fun setAccountId(id: String) { + crashlytics.setUserId(id) + } } diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 30d069901..dfb0cf313 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -23,6 +23,7 @@ import com.crisiscleanup.feature.caseeditor.navigation.existingCaseTransferWorkT import com.crisiscleanup.feature.caseeditor.navigation.navigateToCaseAddFlag import com.crisiscleanup.feature.caseeditor.navigation.navigateToCaseEditor import com.crisiscleanup.feature.caseeditor.navigation.navigateToTransferWorkType +import com.crisiscleanup.feature.caseeditor.navigation.navigateToViewCase import com.crisiscleanup.feature.caseeditor.navigation.rerouteToCaseChange import com.crisiscleanup.feature.cases.navigation.casesFilterScreen import com.crisiscleanup.feature.cases.navigation.casesGraph @@ -30,6 +31,10 @@ import com.crisiscleanup.feature.cases.navigation.casesSearchScreen import com.crisiscleanup.feature.cases.navigation.navigateToCasesFilter import com.crisiscleanup.feature.cases.navigation.navigateToCasesSearch import com.crisiscleanup.feature.cases.ui.CasesAction +import com.crisiscleanup.feature.crisiscleanuplists.navigation.listsScreen +import com.crisiscleanup.feature.crisiscleanuplists.navigation.navigateToLists +import com.crisiscleanup.feature.crisiscleanuplists.navigation.navigateToViewList +import com.crisiscleanup.feature.crisiscleanuplists.navigation.viewListScreen import com.crisiscleanup.feature.dashboard.navigation.dashboardScreen import com.crisiscleanup.feature.mediamanage.navigation.viewSingleImageScreen import com.crisiscleanup.feature.mediamanage.navigation.viewWorksiteImagesScreen @@ -87,6 +92,12 @@ fun CrisisCleanupNavHost( { ids: ExistingWorksiteIdentifier -> navController.rerouteToCaseChange(ids) } } + val openViewCase = remember(navController) { + { ids: ExistingWorksiteIdentifier -> + navController.navigateToViewCase(ids.incidentId, ids.worksiteId) + } + } + val openFilterCases = remember(navController) { { navController.navigateToCasesFilter() } } val openInviteTeammate = @@ -97,6 +108,13 @@ fun CrisisCleanupNavHost( val openUserFeedback = remember(navController) { { navController.navigateToUserFeedback() } } + val openLists = remember(navController) { { navController.navigateToLists() } } + val openList = remember(navController) { + { listId: Long -> + navController.navigateToViewList(listId) + } + } + val openSyncLogs = remember(navController) { { navController.navigateToSyncInsights() } } val navToCaseAddFlagNonEditing = @@ -134,6 +152,7 @@ fun CrisisCleanupNavHost( teamScreen() menuScreen( openAuthentication = openAuthentication, + openLists = openLists, openInviteTeammate = openInviteTeammate, openRequestRedeploy = openRequestRedeploy, openUserFeedback = openUserFeedback, @@ -144,6 +163,12 @@ fun CrisisCleanupNavHost( inviteTeammateScreen(onBack) requestRedeployScreen(onBack) userFeedbackScreen(onBack) + listsScreen(navController, onBack) + viewListScreen( + onBack, + openList = openList, + openWorksite = openViewCase, + ) syncInsightsScreen(viewCase) resetPasswordScreen( diff --git a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt index 7d887db55..a2dbee231 100644 --- a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt +++ b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt @@ -49,4 +49,7 @@ object RouteConstant { const val WORKSITE_IMAGES_ROUTE = "worksite_images" const val ACCOUNT_RESET_PASSWORD_ROUTE = "account_reset_password_route" + + const val LISTS_ROUTE = "crisis_cleanup_lists" + const val VIEW_LIST_ROUTE = "view_list" } diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/PhoneNumberUtil.kt b/core/common/src/main/java/com/crisiscleanup/core/common/PhoneNumberUtil.kt index 8c5b3291a..d5b45baee 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/PhoneNumberUtil.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/PhoneNumberUtil.kt @@ -12,11 +12,10 @@ object PhoneNumberUtil { private val twoPhoneNumbersRegex = """^(\d{10,11})\D+(\d{10,11})$""".toRegex() fun getPhoneNumbers(possiblePhoneNumbers: List) = possiblePhoneNumbers - .filter { s -> s?.isNotBlank() == true } - .map { s -> s!! } + .filter { it?.isNotBlank() == true } + .map { it!! } .map { phoneIn -> val filtered = phoneIn.trim() - .trim() val cleaned = filtered.replace(bracketsDashRegex, "") .replace(letterRegex, " ") .replace(twoPlusSpacesRegex, " ") diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/QueryParameter.kt b/core/common/src/main/java/com/crisiscleanup/core/common/QueryParameter.kt new file mode 100644 index 000000000..0ae4b4724 --- /dev/null +++ b/core/common/src/main/java/com/crisiscleanup/core/common/QueryParameter.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.core.common + +import android.net.Uri + +val Uri.queryParamMap: Map + get() { + val params = mutableMapOf() + for (key in queryParameterNames) { + getQueryParameter(key)?.let { value -> + params[key] = value + } + } + return params + } \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt index cdecbcba2..9138d6296 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt @@ -40,6 +40,34 @@ object LoggersModule { return logger } + @Provides + @Logger(CrisisCleanupLoggers.Incidents) + fun providesIncidentsLogger(logger: TagLogger): AppLogger { + logger.tag = "incidents" + return logger + } + + @Provides + @Logger(CrisisCleanupLoggers.Language) + fun providesLanguageLogger(logger: TagLogger): AppLogger { + logger.tag = "language" + return logger + } + + @Provides + @Logger(CrisisCleanupLoggers.Lists) + fun providesListsLogger(logger: TagLogger): AppLogger { + logger.tag = "lists" + return logger + } + + @Provides + @Logger(CrisisCleanupLoggers.Media) + fun providesMediaLogger(logger: TagLogger): AppLogger { + logger.tag = "media" + return logger + } + @Provides @Logger(CrisisCleanupLoggers.Navigation) fun providesNavLogger(logger: TagLogger): AppLogger { @@ -75,31 +103,10 @@ object LoggersModule { return logger } - @Provides - @Logger(CrisisCleanupLoggers.Incidents) - fun providesIncidentsLogger(logger: TagLogger): AppLogger { - logger.tag = "incidents" - return logger - } - @Provides @Logger(CrisisCleanupLoggers.Worksites) fun providesWorksitesLogger(logger: TagLogger): AppLogger { logger.tag = "worksites" return logger } - - @Provides - @Logger(CrisisCleanupLoggers.Language) - fun providesLanguageLogger(logger: TagLogger): AppLogger { - logger.tag = "language" - return logger - } - - @Provides - @Logger(CrisisCleanupLoggers.Media) - fun providesMediaLogger(logger: TagLogger): AppLogger { - logger.tag = "media" - return logger - } } diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt b/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt index cba0f18ad..2fb9591ae 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt @@ -8,6 +8,8 @@ interface AppLogger { fun logException(e: Exception) fun logCapture(message: String) + + fun setAccountId(id: String) } interface TagLogger : AppLogger { @@ -25,6 +27,7 @@ enum class CrisisCleanupLoggers { Cases, Incidents, Language, + Lists, Media, Navigation, Network, diff --git a/core/commoncase/src/main/java/CommonCaseConstants.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/CommonCaseConstants.kt similarity index 100% rename from core/commoncase/src/main/java/CommonCaseConstants.kt rename to core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/CommonCaseConstants.kt diff --git a/core/commoncase/src/main/java/TransferWorkTypeProvider.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/TransferWorkTypeProvider.kt similarity index 100% rename from core/commoncase/src/main/java/TransferWorkTypeProvider.kt rename to core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/TransferWorkTypeProvider.kt diff --git a/core/commoncase/src/main/java/WorksiteProvider.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/WorksiteProvider.kt similarity index 100% rename from core/commoncase/src/main/java/WorksiteProvider.kt rename to core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/WorksiteProvider.kt diff --git a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/ExplainWrongLocationDialog.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/ExplainWrongLocationDialog.kt index 90aef6f7d..e680c42e0 100644 --- a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/ExplainWrongLocationDialog.kt +++ b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/ExplainWrongLocationDialog.kt @@ -1,4 +1,4 @@ -package com.crisiscleanup.core.commoncase.com.crisiscleanup.core.commoncase.ui +package com.crisiscleanup.core.commoncase.ui import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue diff --git a/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt new file mode 100644 index 000000000..e7ee2d154 --- /dev/null +++ b/core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt @@ -0,0 +1,104 @@ +package com.crisiscleanup.core.commoncase.ui + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.commonassets.DisasterIcon +import com.crisiscleanup.core.commonassets.R +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.CrisisCleanupIconButton +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemHeight +import com.crisiscleanup.core.designsystem.theme.listItemPadding +import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy +import com.crisiscleanup.core.designsystem.theme.primaryOrangeColor + +@Composable +fun IncidentHeaderView( + modifier: Modifier = Modifier, + incidentName: String = "", + @DrawableRes disasterResId: Int, + isPendingSync: Boolean = false, + isSyncing: Boolean = false, + scheduleSync: () -> Unit = {}, +) { + Row( + modifier = modifier + .listItemPadding() + .listItemHeight(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = listItemSpacedBy, + ) { + DisasterIcon(disasterResId, incidentName) + Text( + incidentName, + Modifier + .testTag("caseViewIncidentName") + .weight(1f), + style = LocalFontStyles.current.header1, + ) + + val t = LocalAppTranslator.current + if (isSyncing) { + Box( + // minimumInteractiveComponentSize > IconButtonTokens.StateLayerSize + modifier = Modifier.size(48.dp), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = CrisisCleanupIcons.CloudSync, + contentDescription = t("info.is_syncing"), + modifier = Modifier.testTag("caseViewIsSyncingIcon"), + ) + } + } else if (isPendingSync) { + CrisisCleanupIconButton( + onClick = scheduleSync, + imageVector = CrisisCleanupIcons.Cloud, + contentDescription = t("info.is_pending_sync"), + tint = primaryOrangeColor, + modifier = Modifier.testTag("caseViewIsPendingSyncIconBtn"), + ) + } + } +} + +@Preview("syncing") +@Composable +private fun IncidentHeaderSyncingPreview() { + CrisisCleanupTheme { + Surface { + IncidentHeaderView( + incidentName = "Big hurricane", + disasterResId = R.drawable.ic_flood_thunder, + isSyncing = true, + ) + } + } +} + +@Preview("pending-sync") +@Composable +private fun IncidentHeaderPendingSyncPreview() { + CrisisCleanupTheme { + Surface { + IncidentHeaderView( + incidentName = "Big hurricane", + disasterResId = R.drawable.ic_flood_thunder, + isPendingSync = true, + ) + } + } +} diff --git a/core/data/build.gradle.kts b/core/data/build.gradle.kts index a58213cfc..7d1a60038 100644 --- a/core/data/build.gradle.kts +++ b/core/data/build.gradle.kts @@ -25,6 +25,7 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.androidx.compose.runtime) + implementation(libs.androidx.paging.common) implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.coroutines.android) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt index 240500994..1946ec5c9 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt @@ -16,6 +16,7 @@ import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntit import com.crisiscleanup.core.database.model.PopulatedIncidentSyncStats import com.crisiscleanup.core.database.model.SwNeBounds import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.model.NetworkWorksiteFull import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow @@ -260,7 +261,7 @@ class IncidentWorksitesFullSyncer @Inject constructor( queryCount + (worksites.size * 0.5f).toInt(), approximateTotalCount, ) - val entities = worksites.map { it.asEntities() } + val entities = worksites.map(NetworkWorksiteFull::asEntities) worksiteDaoPlus.syncWorksites(entities, syncStartedAt) queryCount += worksites.size diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt new file mode 100644 index 000000000..90bfbb6c8 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt @@ -0,0 +1,75 @@ +package com.crisiscleanup.core.data + +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.repository.ListsRepository +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.model.NetworkList +import com.crisiscleanup.core.network.model.tryThrowException +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.ensureActive +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +interface ListsSyncer { + suspend fun sync() +} + +// TODO Test coverage + +class AccountListsSyncer @Inject constructor( + private val networkDataSource: CrisisCleanupNetworkDataSource, + private val listsRepository: ListsRepository, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, +) : ListsSyncer { + private val syncGuard = AtomicBoolean(false) + + override suspend fun sync() = coroutineScope { + if (syncGuard.getAndSet(true)) { + return@coroutineScope + } + + var networkCount = 0 + var requestingCount = 0 + val cachedLists = mutableListOf() + try { + while (networkCount == 0 || requestingCount < networkCount) { + val result = networkDataSource.getLists(1000, requestingCount) + result.errors?.tryThrowException() + + if (networkCount == 0) { + networkCount = result.count!! + } + + result.results?.let { lists -> + requestingCount += lists.size + cachedLists.addAll(lists) + } + + if ((result.results?.size ?: 0) == 0) { + break + } + + // TODO Cache data to file if gets too large + if (cachedLists.size > 10000) { + logger.logException(Exception("Ignoring lists beyond ${cachedLists.size}")) + break + } + + ensureActive() + } + + listsRepository.syncLists(cachedLists) + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + + logger.logException(e) + } finally { + syncGuard.set(false) + } + } +} 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 2f5cc64f2..d9b7472a0 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 @@ -2,11 +2,13 @@ package com.crisiscleanup.core.data.di import com.crisiscleanup.core.common.KeyTranslator import com.crisiscleanup.core.common.NetworkMonitor +import com.crisiscleanup.core.data.AccountListsSyncer import com.crisiscleanup.core.data.IncidentOrganizationsDataCache import com.crisiscleanup.core.data.IncidentOrganizationsDataFileCache import com.crisiscleanup.core.data.IncidentWorksitesFullSyncer import com.crisiscleanup.core.data.IncidentWorksitesSecondaryDataSyncer import com.crisiscleanup.core.data.IncidentWorksitesSyncer +import com.crisiscleanup.core.data.ListsSyncer import com.crisiscleanup.core.data.SyncCacheDeviceInspector import com.crisiscleanup.core.data.WorksitesFullSyncer import com.crisiscleanup.core.data.WorksitesNetworkDataCache @@ -26,6 +28,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupAccountDataRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountUpdateRepository import com.crisiscleanup.core.data.repository.CrisisCleanupCasesFilterRepository import com.crisiscleanup.core.data.repository.CrisisCleanupDataManagementRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupListsRepository import com.crisiscleanup.core.data.repository.CrisisCleanupLocalImageRepository import com.crisiscleanup.core.data.repository.CrisisCleanupOrgVolunteerRepository import com.crisiscleanup.core.data.repository.CrisisCleanupRequestRedeployRepository @@ -34,6 +37,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupWorksiteChangeReposit import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository +import com.crisiscleanup.core.data.repository.ListsRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository import com.crisiscleanup.core.data.repository.LocalImageRepository @@ -202,6 +206,11 @@ interface DataModule { fun bindsWorksiteImageRepository( repository: OfflineFirstWorksiteImageRepository, ): WorksiteImageRepository + + @Binds + fun bindsListRepository( + repository: CrisisCleanupListsRepository, + ): ListsRepository } @Module @@ -226,4 +235,7 @@ interface DataInternalModule { fun providesIncidentOrganizationsNetworkDataCache( cache: IncidentOrganizationsDataFileCache, ): IncidentOrganizationsDataCache + + @Binds + fun bindsListsSyncer(syncer: AccountListsSyncer): ListsSyncer } 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 61355975f..b41cfde0a 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 @@ -14,6 +14,7 @@ fun NetworkIncident.asEntity() = IncidentEntity( startAt = startAt, name = name, shortName = shortName, + caseLabel = caseLabel, // Active phone numbers are unique to incidents and not complex in structure so treat as comma delimited string value activePhoneNumber = activePhoneNumber?.joinToString(",", transform = String::trim), turnOnRelease = turnOnRelease, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkList.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkList.kt new file mode 100644 index 000000000..0b80e0367 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkList.kt @@ -0,0 +1,24 @@ +package com.crisiscleanup.core.data.model + +import com.crisiscleanup.core.database.model.ListEntity +import com.crisiscleanup.core.network.model.NetworkList + +internal fun NetworkList.asEntity() = ListEntity( + id = 0, + networkId = id, + localGlobalUuid = "", + createdBy = createdBy, + updatedBy = updatedBy, + createdAt = createdAt, + updatedAt = updatedAt, + parent = parent, + name = name, + description = description, + listOrder = listOrder, + tags = tags, + model = model, + objectIds = (objectIds ?: emptyList()).joinToString(","), + shared = shared, + permissions = permissions, + incidentId = incident, +) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CaseHistoryRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CaseHistoryRepository.kt index 7b407fcac..75b1ea7bb 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CaseHistoryRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CaseHistoryRepository.kt @@ -3,13 +3,10 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Cases import com.crisiscleanup.core.common.log.Logger -import com.crisiscleanup.core.data.model.PersonContactEntities import com.crisiscleanup.core.data.model.asEntities import com.crisiscleanup.core.database.dao.CaseHistoryDao import com.crisiscleanup.core.database.dao.CaseHistoryDaoPlus -import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus import com.crisiscleanup.core.database.dao.PersonContactDao -import com.crisiscleanup.core.database.dao.PersonContactDaoPlus import com.crisiscleanup.core.database.dao.WorksiteDao import com.crisiscleanup.core.database.model.PopulatedCaseHistoryEvent import com.crisiscleanup.core.database.model.asExternalModel @@ -17,7 +14,6 @@ import com.crisiscleanup.core.model.data.CaseHistoryEvent import com.crisiscleanup.core.model.data.CaseHistoryUserEvents import com.crisiscleanup.core.model.data.EmptyWorksite import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource -import com.crisiscleanup.core.network.model.NetworkPersonContact import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.Flow @@ -43,8 +39,7 @@ class OfflineFirstCaseHistoryRepository @Inject constructor( private val worksiteDao: WorksiteDao, private val networkDataSource: CrisisCleanupNetworkDataSource, private val caseHistoryDaoPlus: CaseHistoryDaoPlus, - private val personContactDaoPlus: PersonContactDaoPlus, - private val incidentOrganizationDaoPlus: IncidentOrganizationDaoPlus, + private val usersRepository: UsersRepository, private val translator: LanguageTranslationsRepository, @Logger(Cases) private val logger: AppLogger, ) : CaseHistoryRepository { @@ -89,7 +84,7 @@ class OfflineFirstCaseHistoryRepository @Inject constructor( ensureActive() val userIds = userEventMap.keys - queryUpdateUsers(userIds) + usersRepository.queryUpdateUsers(userIds) ensureActive() @@ -117,23 +112,6 @@ class OfflineFirstCaseHistoryRepository @Inject constructor( .map { it.first } } - private suspend fun queryUpdateUsers(userIds: Collection) { - try { - val networkUsers = networkDataSource.getUsers(userIds) - val entities = networkUsers.mapNotNull(NetworkPersonContact::asEntities) - - val organizations = entities.map(PersonContactEntities::organization) - val affiliates = entities.map(PersonContactEntities::organizationAffiliates) - incidentOrganizationDaoPlus.saveMissing(organizations, affiliates) - - val persons = entities.map(PersonContactEntities::personContact) - val personOrganizations = entities.map(PersonContactEntities::personToOrganization) - personContactDaoPlus.savePersons(persons, personOrganizations) - } catch (e: Exception) { - logger.logException(e) - } - } - override suspend fun refreshEvents(worksiteId: Long): Int { refreshingWorksiteEvents.value = worksiteId try { diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt index ec0389c93..23406260c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt @@ -131,6 +131,7 @@ class CrisisCleanupAccountDataRepository @Inject constructor( private suspend fun onLogout() { dataSource.clearAccount() + logger.setAccountId("") } // For dev diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListDataRefresher.kt new file mode 100644 index 000000000..5d9e67cb4 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListDataRefresher.kt @@ -0,0 +1,36 @@ +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.ListsSyncer +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration +import kotlin.time.Duration.Companion.hours + +@Singleton +class ListDataRefresher @Inject constructor( + private val listsSyncer: ListsSyncer, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, +) { + private var dataUpdateTime = Instant.fromEpochSeconds(0) + + suspend fun refreshListData( + force: Boolean = false, + cacheTimeSpan: Duration = 1.hours, + ) { + if (!force && dataUpdateTime.plus(cacheTimeSpan) > Clock.System.now()) { + return + } + + try { + listsSyncer.sync() + dataUpdateTime = Clock.System.now() + } catch (e: Exception) { + logger.logException(e) + } + } +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListsRepository.kt new file mode 100644 index 000000000..b8e169fa9 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListsRepository.kt @@ -0,0 +1,252 @@ +package com.crisiscleanup.core.data.repository + +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +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.common.split +import com.crisiscleanup.core.data.model.asEntities +import com.crisiscleanup.core.data.model.asEntity +import com.crisiscleanup.core.database.dao.IncidentDao +import com.crisiscleanup.core.database.dao.IncidentOrganizationDao +import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus +import com.crisiscleanup.core.database.dao.ListDao +import com.crisiscleanup.core.database.dao.ListDaoPlus +import com.crisiscleanup.core.database.dao.PersonContactDao +import com.crisiscleanup.core.database.dao.WorksiteDao +import com.crisiscleanup.core.database.dao.WorksiteDaoPlus +import com.crisiscleanup.core.database.model.PopulatedIncident +import com.crisiscleanup.core.database.model.PopulatedIncidentOrganization +import com.crisiscleanup.core.database.model.PopulatedList +import com.crisiscleanup.core.database.model.PopulatedWorksite +import com.crisiscleanup.core.database.model.asExternalModel +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentOrganization +import com.crisiscleanup.core.model.data.ListModel +import com.crisiscleanup.core.model.data.PersonContact +import com.crisiscleanup.core.model.data.Worksite +import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.model.CrisisCleanupNetworkException +import com.crisiscleanup.core.network.model.NetworkIncidentOrganization +import com.crisiscleanup.core.network.model.NetworkList +import com.crisiscleanup.core.network.model.NetworkWorksiteFull +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import kotlinx.datetime.Clock +import javax.inject.Inject + +interface ListsRepository { + fun streamIncidentLists(incidentId: Long): Flow> + + fun getIncidentListCount(incidentId: Long): Int + + fun pageLists(): Flow> + + fun streamList(listId: Long): Flow + + suspend fun syncLists(lists: List) + + suspend fun refreshList(id: Long) + + suspend fun getListObjectData(list: CrisisCleanupList): Map +} + +class CrisisCleanupListsRepository @Inject constructor( + private val listDao: ListDao, + private val listDaoPlus: ListDaoPlus, + private val incidentDao: IncidentDao, + private val organizationDao: IncidentOrganizationDao, + private val incidentsRepository: IncidentsRepository, + private val organizationDaoPlus: IncidentOrganizationDaoPlus, + private val networkDataSource: CrisisCleanupNetworkDataSource, + private val personContactDao: PersonContactDao, + private val usersRepository: UsersRepository, + private val worksiteDao: WorksiteDao, + private val worksiteDaoPlus: WorksiteDaoPlus, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, +) : ListsRepository { + private val listPager = Pager( + config = PagingConfig(pageSize = 30), + pagingSourceFactory = { + listDao.pageLists() + }, + ) + + override fun streamIncidentLists(incidentId: Long) = + listDao.streamIncidentLists(incidentId).map { it.map(PopulatedList::asExternalModel) } + + override fun getIncidentListCount(incidentId: Long) = listDao.getIncidentListCount(incidentId) + + override fun pageLists() = listPager.flow.map { + it.map(PopulatedList::asExternalModel) + } + + override fun streamList(listId: Long) = + listDao.streamList(listId).map { it?.asExternalModel() ?: EmptyList } + + override suspend fun syncLists(lists: List) { + val (validLists, invalidLists) = lists.split { + it.invalidateAt == null + } + + val listEntities = validLists.map(NetworkList::asEntity) + val invalidNetworkIds = invalidLists.map(NetworkList::id).toSet() + listDaoPlus.syncUpdateLists(listEntities, invalidNetworkIds) + } + + override suspend fun refreshList(id: Long) { + listDao.getList(id)?.let { cachedList -> + if (cachedList.networkId > 0) { + // TODO Skip update where locally modified + // How to handle delete where update exists? Should delete for consistency. + try { + networkDataSource.getList(cachedList.networkId) + ?.asEntity() + ?.let { + with(it) { + listDao.syncUpdateList( + networkId = networkId, + updatedBy = updatedBy, + updatedAt = updatedAt, + parent = parent, + name = name, + description = description ?: "", + listOrder = listOrder, + tags = tags ?: "", + model = model, + objectIds = objectIds, + shared = shared, + permissions = permissions, + incident = incidentId, + ) + } + } + } catch (e: Exception) { + (e as? CrisisCleanupNetworkException)?.statusCode?.let { code -> + if (code == 404) { + listDao.deleteList(id) + return + } + } + logger.logException(e) + } + } + } + } + + override suspend fun getListObjectData(list: CrisisCleanupList): Map { + if (list.incidentId > 0 && list.incident == null) { + incidentsRepository.pullIncident(list.incidentId) + } + + val objectIds = list.objectIds.toSet() + + when (list.model) { + ListModel.Incident -> { + val incidents = incidentDao.getIncidents(objectIds) + .map(PopulatedIncident::asExternalModel) + return incidents.associateBy(Incident::id) + } + + ListModel.List -> { + fun getListLookup() = listDao.getListsByNetworkIds(objectIds) + .map(PopulatedList::asExternalModel) + .associateBy(CrisisCleanupList::networkId) + + var listLookup = getListLookup() + if (listLookup.size != objectIds.size) { + val networkListIds = objectIds.filter { !listLookup.containsKey(it) } + // Guard against infinite refresh + .filter { it != list.networkId } + try { + val listEntities = networkDataSource.getLists(networkListIds) + .mapNotNull { it?.asEntity() } + listDaoPlus.syncUpdateLists(listEntities, emptySet()) + + listLookup = getListLookup() + } catch (e: Exception) { + logger.logException(e) + } + } + return listLookup + } + + ListModel.Organization -> { + fun getOrganizationLookup() = organizationDao.getOrganizations(objectIds) + .map(PopulatedIncidentOrganization::asExternalModel) + .associateBy(IncidentOrganization::id) + + var organizationLookup = getOrganizationLookup() + if (organizationLookup.size != objectIds.size) { + val networkOrgIds = objectIds.filter { !organizationLookup.containsKey(it) } + try { + val organizationEntities = networkDataSource.getOrganizations(networkOrgIds) + .map(NetworkIncidentOrganization::asEntity) + organizationDaoPlus.saveOrganizations( + organizationEntities, + // TODO Save contacts and related data from network data. See IncidentOrganizationsSyncer for reference. + emptyList(), + ) + + organizationLookup = getOrganizationLookup() + } catch (e: Exception) { + logger.logException(e) + } + } + return organizationLookup + } + + ListModel.User -> { + fun getContactLookup() = personContactDao.getContacts(objectIds) + .map { it.entity.asExternalModel() } + .associateBy(PersonContact::id) + + var contactLookup = getContactLookup() + if (contactLookup.size != objectIds.size) { + val userIds = objectIds.filter { !contactLookup.containsKey(it) } + try { + usersRepository.queryUpdateUsers(userIds) + contactLookup = getContactLookup() + } catch (e: Exception) { + logger.logException(e) + } + } + return contactLookup + } + + ListModel.Worksite -> { + fun getNetworkWorksiteLookup() = worksiteDao.getWorksitesByNetworkId(objectIds) + .map(PopulatedWorksite::asExternalModel) + .filter { it.networkId > 0 } + .associateBy(Worksite::networkId) + + var networkWorksiteLookup = getNetworkWorksiteLookup() + if (networkWorksiteLookup.size != objectIds.size) { + val worksiteIds = objectIds.filter { !networkWorksiteLookup.containsKey(it) } + try { + val syncedAt = Clock.System.now() + networkDataSource.getWorksites(worksiteIds)?.let { networkWorksites -> + val entities = networkWorksites + .map(NetworkWorksiteFull::asEntities) + worksiteDaoPlus.syncWorksites(entities, syncedAt) + } + + networkWorksiteLookup = getNetworkWorksiteLookup() + } catch (e: Exception) { + logger.logException(e) + } + } + return networkWorksiteLookup + } + + else -> {} + } + + return emptyMap() + } +} 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 6961dfdc8..3204556fd 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 @@ -54,6 +54,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( "start_at", "name", "short_name", + "case_label", "incident_type", "locations", "turn_on_release", @@ -129,7 +130,7 @@ class OfflineFirstIncidentsRepository @Inject constructor( } val queryFields: List val pullAfter: Instant? - val recentTimestamp = Clock.System.now() - 120.days + val recentTimestamp = Clock.System.now() - 180.days if (pullAll) { queryFields = incidentsQueryFields pullAfter = null diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt index d1f16e8a9..754bc03f4 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstWorksitesRepository.kt @@ -312,6 +312,14 @@ class OfflineFirstWorksitesRepository @Inject constructor( return worksiteDaoPlus.syncNetworkWorksite(entities, syncedAt) } + override suspend fun syncNetworkWorksite(networkWorksiteId: Long) { + val syncedAt = Clock.System.now() + // TODO Sync related data including secondary, work type requests, ... + dataSource.getWorksite(networkWorksiteId)?.let { networkWorksite -> + syncNetworkWorksite(networkWorksite, syncedAt) + } + } + override suspend fun pullWorkTypeRequests(networkWorksiteId: Long) { try { val workTypeRequests = dataSource.getWorkTypeRequests(networkWorksiteId) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/SyncLogRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/SyncLogRepository.kt index dcf5204fd..3103d3170 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/SyncLogRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/SyncLogRepository.kt @@ -1,6 +1,11 @@ package com.crisiscleanup.core.data.repository import android.util.Log +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.PagingData +import androidx.paging.map +import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.di.ApplicationScope import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher @@ -17,6 +22,7 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -26,37 +32,51 @@ import javax.inject.Inject interface SyncLogRepository { fun streamLogCount(): Flow - fun getLogs(limit: Int, offset: Int): List + fun pageLogs(): Flow> fun trimOldLogs() } class PagingSyncLogRepository @Inject constructor( private val syncLogDao: SyncLogDao, + private val appEnv: AppEnv, @ApplicationScope private val coroutineScope: CoroutineScope, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, ) : SyncLogger, SyncLogRepository { private val logEntriesMutex = Mutex() private var logEntries = mutableListOf() + private val syncLogPager = Pager( + config = PagingConfig(pageSize = 30), + pagingSourceFactory = { + syncLogDao.pageSyncLogs() + }, + ) + override var type = "" override fun log(message: String, details: String, type: String): SyncLogger { - // TODO Enable logging only if dev mode/sync logging is enabled - logEntries.add( - SyncLog( - 0, - Clock.System.now(), - logType = type.ifEmpty { this.type }, - message = message, - details = details, - ), - ) + // TODO Log if sync logging is enabled + if (appEnv.isNotProduction) { + logEntries.add( + SyncLog( + 0, + Clock.System.now(), + logType = type.ifEmpty { this.type }, + message = message, + details = details, + ), + ) + } return this } override fun clear(): SyncLogger { - logEntries = mutableListOf() + coroutineScope.launch(ioDispatcher) { + logEntriesMutex.withLock { + logEntries = mutableListOf() + } + } return this } @@ -79,8 +99,9 @@ class PagingSyncLogRepository @Inject constructor( override fun streamLogCount() = syncLogDao.streamLogCount() - override fun getLogs(limit: Int, offset: Int) = - syncLogDao.getSyncLogs(limit, offset).map(PopulatedSyncLog::asExternalModel) + override fun pageLogs() = syncLogPager.flow.map { + it.map(PopulatedSyncLog::asExternalModel) + } override fun trimOldLogs() = syncLogDao.trimOldSyncLogs() } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/UsersRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/UsersRepository.kt index 36d80ffed..f92745623 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/UsersRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/UsersRepository.kt @@ -3,7 +3,11 @@ 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.model.PersonContactEntities +import com.crisiscleanup.core.data.model.asEntities import com.crisiscleanup.core.data.model.asExternalModel +import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus +import com.crisiscleanup.core.database.dao.PersonContactDaoPlus import com.crisiscleanup.core.model.data.PersonContact import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkPersonContact @@ -15,10 +19,14 @@ interface UsersRepository { organization: Long, limit: Int = 10, ): List + + suspend fun queryUpdateUsers(userIds: Collection) } class OfflineFirstUsersRepository @Inject constructor( private val networkDataSource: CrisisCleanupNetworkDataSource, + private val personContactDaoPlus: PersonContactDaoPlus, + private val incidentOrganizationDaoPlus: IncidentOrganizationDaoPlus, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : UsersRepository { override suspend fun getMatchingUsers( @@ -34,4 +42,21 @@ class OfflineFirstUsersRepository @Inject constructor( } return emptyList() } + + override suspend fun queryUpdateUsers(userIds: Collection) { + try { + val networkUsers = networkDataSource.getUsers(userIds) + val entities = networkUsers.mapNotNull(NetworkPersonContact::asEntities) + + val organizations = entities.map(PersonContactEntities::organization) + val affiliates = entities.map(PersonContactEntities::organizationAffiliates) + incidentOrganizationDaoPlus.saveMissing(organizations, affiliates) + + val persons = entities.map(PersonContactEntities::personContact) + val personOrganizations = entities.map(PersonContactEntities::personToOrganization) + personContactDaoPlus.savePersons(persons, personOrganizations) + } catch (e: Exception) { + logger.logException(e) + } + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt index 25a08745d..25b6271e1 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/WorksitesRepository.kt @@ -71,6 +71,8 @@ interface WorksitesRepository { syncedAt: Instant = Clock.System.now(), ): Boolean + suspend fun syncNetworkWorksite(networkWorksiteId: Long) + suspend fun pullWorkTypeRequests(networkWorksiteId: Long) suspend fun setRecentWorksite( diff --git a/core/database/build.gradle.kts b/core/database/build.gradle.kts index 5145eefa0..f0b4ff554 100644 --- a/core/database/build.gradle.kts +++ b/core/database/build.gradle.kts @@ -32,6 +32,8 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) + implementation(libs.androidx.paging.common) + implementation(libs.room.paging) androidTestImplementation(projects.core.testing) androidTestImplementation(libs.room.testing) diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json new file mode 100644 index 000000000..6d8a671ab --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json @@ -0,0 +1,2999 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "23e4bda629525238defdb9a682b03d81", + "entities": [ + { + "tableName": "work_type_statuses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`status` TEXT NOT NULL, `name` TEXT NOT NULL, `list_order` INTEGER NOT NULL, `primary_state` TEXT NOT NULL, PRIMARY KEY(`status`))", + "fields": [ + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryState", + "columnName": "primary_state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "status" + ] + }, + "indices": [ + { + "name": "index_work_type_statuses_list_order", + "unique": false, + "columnNames": [ + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_type_statuses_list_order` ON `${TABLE_NAME}` (`list_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "incidents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `start_at` INTEGER NOT NULL, `name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', `active_phone_number` TEXT DEFAULT '', `turn_on_release` INTEGER NOT NULL DEFAULT 0, `is_archived` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "startAt", + "columnName": "start_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "activePhoneNumber", + "columnName": "active_phone_number", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "turnOnRelease", + "columnName": "turn_on_release", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "is_archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_newest_to_oldest_incidents", + "unique": false, + "columnNames": [ + "start_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_newest_to_oldest_incidents` ON `${TABLE_NAME}` (`start_at` DESC)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "incident_locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `location` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "incident_to_incident_location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `incident_location_id` INTEGER NOT NULL, PRIMARY KEY(`incident_id`, `incident_location_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`incident_location_id`) REFERENCES `incident_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentLocationId", + "columnName": "incident_location_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "incident_location_id" + ] + }, + "indices": [ + { + "name": "idx_incident_location_to_incident", + "unique": false, + "columnNames": [ + "incident_location_id", + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_incident_location_to_incident` ON `${TABLE_NAME}` (`incident_location_id`, `incident_id`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_form_fields", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `label` TEXT NOT NULL, `html_type` TEXT NOT NULL, `data_group` TEXT NOT NULL, `help` TEXT DEFAULT '', `placeholder` TEXT DEFAULT '', `read_only_break_glass` INTEGER NOT NULL, `values_default_json` TEXT DEFAULT '', `is_checkbox_default_true` INTEGER DEFAULT 0, `order_label` INTEGER NOT NULL DEFAULT -1, `validation` TEXT DEFAULT '', `recur_default` TEXT DEFAULT '0', `values_json` TEXT DEFAULT '', `is_required` INTEGER DEFAULT 0, `is_read_only` INTEGER DEFAULT 0, `list_order` INTEGER NOT NULL, `is_invalidated` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `field_parent_key` TEXT DEFAULT '', `parent_key` TEXT NOT NULL DEFAULT '', `selected_toggle_work_type` TEXT DEFAULT '', PRIMARY KEY(`incident_id`, `parent_key`, `field_key`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlType", + "columnName": "html_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataGroup", + "columnName": "data_group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "help", + "columnName": "help", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "placeholder", + "columnName": "placeholder", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "readOnlyBreakGlass", + "columnName": "read_only_break_glass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesDefaultJson", + "columnName": "values_default_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isCheckboxDefaultTrue", + "columnName": "is_checkbox_default_true", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "orderLabel", + "columnName": "order_label", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "validation", + "columnName": "validation", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "recurDefault", + "columnName": "recur_default", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "'0'" + }, + { + "fieldPath": "valuesJson", + "columnName": "values_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInvalidated", + "columnName": "is_invalidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldParentKey", + "columnName": "field_parent_key", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentKeyNonNull", + "columnName": "parent_key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "selectToggleWorkType", + "columnName": "selected_toggle_work_type", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "parent_key", + "field_key" + ] + }, + "indices": [ + { + "name": "index_incident_form_fields_data_group_parent_key_list_order", + "unique": false, + "columnNames": [ + "data_group", + "parent_key", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_form_fields_data_group_parent_key_list_order` ON `${TABLE_NAME}` (`data_group`, `parent_key`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `shape_type` TEXT NOT NULL DEFAULT '', `coordinates` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shapeType", + "columnName": "shape_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "coordinates", + "columnName": "coordinates", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksite_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksites_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksites_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_worksites_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `address` TEXT NOT NULL, `auto_contact_frequency_t` TEXT, `case_number` TEXT NOT NULL, `case_number_order` INTEGER NOT NULL DEFAULT 0, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `created_at` INTEGER, `email` TEXT DEFAULT '', `favorite_id` INTEGER, `key_work_type_type` TEXT NOT NULL DEFAULT '', `key_work_type_org` INTEGER, `key_work_type_status` TEXT NOT NULL DEFAULT '', `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT, `phone2` TEXT DEFAULT '', `plus_code` TEXT DEFAULT '', `postal_code` TEXT NOT NULL, `reported_by` INTEGER, `state` TEXT NOT NULL, `svi` REAL, `what3Words` TEXT DEFAULT '', `updated_at` INTEGER NOT NULL, `is_local_favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoContactFrequencyT", + "columnName": "auto_contact_frequency_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumberOrder", + "columnName": "case_number_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "favoriteId", + "columnName": "favorite_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyWorkTypeType", + "columnName": "key_work_type_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "keyWorkTypeOrgClaim", + "columnName": "key_work_type_org", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyWorkTypeStatus", + "columnName": "key_work_type_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "plusCode", + "columnName": "plus_code", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "postalCode", + "columnName": "postal_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportedBy", + "columnName": "reported_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "svi", + "columnName": "svi", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "what3Words", + "columnName": "what3Words", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalFavorite", + "columnName": "is_local_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksites_incident_id_latitude_longitude", + "unique": false, + "columnNames": [ + "incident_id", + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_latitude_longitude` ON `${TABLE_NAME}` (`incident_id`, `latitude`, `longitude`)" + }, + { + "name": "index_worksites_incident_id_longitude_latitude", + "unique": false, + "columnNames": [ + "incident_id", + "longitude", + "latitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_longitude_latitude` ON `${TABLE_NAME}` (`incident_id`, `longitude`, `latitude`)" + }, + { + "name": "index_worksites_incident_id_svi", + "unique": false, + "columnNames": [ + "incident_id", + "svi" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_svi` ON `${TABLE_NAME}` (`incident_id`, `svi`)" + }, + { + "name": "index_worksites_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id`, `updated_at`)" + }, + { + "name": "index_worksites_incident_id_created_at", + "unique": false, + "columnNames": [ + "incident_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_created_at` ON `${TABLE_NAME}` (`incident_id`, `created_at`)" + }, + { + "name": "index_worksites_incident_id_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_name_county_city_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "name", + "county", + "city", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_name_county_city_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `name`, `county`, `city`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_city_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "city", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_city_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `city`, `name`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_county_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "county", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_county_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `county`, `name`, `case_number_order`, `case_number`)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "work_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER, `claimed_by` INTEGER, `next_recur_at` INTEGER, `phase` INTEGER, `recur` TEXT, `status` TEXT NOT NULL, `work_type` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orgClaim", + "columnName": "claimed_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextRecurAt", + "columnName": "next_recur_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recur", + "columnName": "recur", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_work_type", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_work_type` ON `${TABLE_NAME}` (`worksite_id`, `work_type`)" + }, + { + "name": "index_work_types_worksite_id_network_id", + "unique": false, + "columnNames": [ + "worksite_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_worksite_id_network_id` ON `${TABLE_NAME}` (`worksite_id`, `network_id`)" + }, + { + "name": "index_work_types_status_worksite_id", + "unique": false, + "columnNames": [ + "status", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_status_worksite_id` ON `${TABLE_NAME}` (`status`, `worksite_id`)" + }, + { + "name": "index_work_types_claimed_by_worksite_id", + "unique": false, + "columnNames": [ + "claimed_by", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_claimed_by_worksite_id` ON `${TABLE_NAME}` (`claimed_by`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_form_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `is_bool_value` INTEGER NOT NULL, `value_string` TEXT NOT NULL, `value_bool` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `field_key`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isBoolValue", + "columnName": "is_bool_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valueString", + "columnName": "value_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueBool", + "columnName": "value_bool", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "field_key" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_flags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `action` TEXT, `created_at` INTEGER NOT NULL, `is_high_priority` INTEGER DEFAULT 0, `notes` TEXT DEFAULT '', `reason_t` TEXT NOT NULL, `requested_action` TEXT DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHighPriority", + "columnName": "is_high_priority", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "reasonT", + "columnName": "reason_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedAction", + "columnName": "requested_action", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_flag", + "unique": true, + "columnNames": [ + "worksite_id", + "reason_t" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_flag` ON `${TABLE_NAME}` (`worksite_id`, `reason_t`)" + }, + { + "name": "index_worksite_flags_reason_t", + "unique": false, + "columnNames": [ + "reason_t" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_flags_reason_t` ON `${TABLE_NAME}` (`reason_t`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_global_uuid` TEXT NOT NULL DEFAULT '', `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_survivor` INTEGER NOT NULL, `note` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSurvivor", + "columnName": "is_survivor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_note", + "unique": true, + "columnNames": [ + "worksite_id", + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_note` ON `${TABLE_NAME}` (`worksite_id`, `network_id`, `local_global_uuid`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "language_translations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `translations_json` TEXT, `synced_at` INTEGER DEFAULT 0, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationsJson", + "columnName": "translations_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sync_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `log_time` INTEGER NOT NULL, `log_type` TEXT NOT NULL DEFAULT '', `message` TEXT NOT NULL, `details` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logTime", + "columnName": "log_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logType", + "columnName": "log_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "details", + "columnName": "details", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_logs_log_time", + "unique": false, + "columnNames": [ + "log_time" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_logs_log_time` ON `${TABLE_NAME}` (`log_time` DESC)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "worksite_changes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_version` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `change_model_version` INTEGER NOT NULL, `change_data` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `save_attempt` INTEGER NOT NULL DEFAULT 0, `archive_action` TEXT NOT NULL, `save_attempt_at` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`worksite_id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "changeModelVersion", + "columnName": "change_model_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changeData", + "columnName": "change_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveAttempt", + "columnName": "save_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "archiveAction", + "columnName": "archive_action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveAttemptAt", + "columnName": "save_attempt_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_changes_worksite_id_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_at`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt` ON `${TABLE_NAME}` (`worksite_id`, `save_attempt`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt_at_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt_at", + "created_at" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt_at_created_at` ON `${TABLE_NAME}` (`worksite_id` ASC, `save_attempt_at` ASC, `created_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `primary_location` INTEGER, `secondary_location` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryLocation", + "columnName": "primary_location", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "secondaryLocation", + "columnName": "secondary_location", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "person_contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `email` TEXT NOT NULL, `mobile` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "first_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "last_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mobile", + "columnName": "mobile", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "organization_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organization_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`organization_id`, `contact_id`), FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organization_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_organization", + "unique": false, + "columnNames": [ + "contact_id", + "organization_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_organization` ON `${TABLE_NAME}` (`contact_id`, `organization_id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "organization_to_affiliate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `affiliate_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `affiliate_id`), FOREIGN KEY(`id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affiliateId", + "columnName": "affiliate_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "affiliate_id" + ] + }, + "indices": [ + { + "name": "index_organization_to_affiliate_affiliate_id_id", + "unique": false, + "columnNames": [ + "affiliate_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_organization_to_affiliate_affiliate_id_id` ON `${TABLE_NAME}` (`affiliate_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organization_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `target_count` INTEGER NOT NULL, `successful_sync` INTEGER, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `viewed_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewed_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recent_worksites_incident_id_viewed_at", + "unique": false, + "columnNames": [ + "incident_id", + "viewed_at" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_incident_id_viewed_at` ON `${TABLE_NAME}` (`incident_id` ASC, `viewed_at` DESC)" + }, + { + "name": "index_recent_worksites_viewed_at", + "unique": false, + "columnNames": [ + "viewed_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_viewed_at` ON `${TABLE_NAME}` (`viewed_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_work_type_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `work_type` TEXT NOT NULL, `reason` TEXT NOT NULL, `by_org` INTEGER NOT NULL, `to_org` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `approved_at` INTEGER, `rejected_at` INTEGER, `approved_rejected_reason` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "byOrg", + "columnName": "by_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toOrg", + "columnName": "to_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "approvedAt", + "columnName": "approved_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "approvedRejectedReason", + "columnName": "approved_rejected_reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_work_type_requests_worksite_id_work_type_by_org", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type", + "by_org" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_work_type_by_org` ON `${TABLE_NAME}` (`worksite_id`, `work_type`, `by_org`)" + }, + { + "name": "index_worksite_work_type_requests_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksite_work_type_requests_worksite_id_by_org", + "unique": false, + "columnNames": [ + "worksite_id", + "by_org" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_by_org` ON `${TABLE_NAME}` (`worksite_id`, `by_org`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `file_id` INTEGER NOT NULL DEFAULT 0, `file_type_t` TEXT NOT NULL, `full_url` TEXT, `large_thumbnail_url` TEXT, `mime_content_type` TEXT NOT NULL, `small_thumbnail_url` TEXT, `tag` TEXT, `title` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "file_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fileTypeT", + "columnName": "file_type_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullUrl", + "columnName": "full_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "largeThumbnailUrl", + "columnName": "large_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeContentType", + "columnName": "mime_content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallThumbnailUrl", + "columnName": "small_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksite_to_network_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `network_file_id` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `network_file_id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`network_file_id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkFileId", + "columnName": "network_file_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "network_file_id" + ] + }, + "indices": [ + { + "name": "index_worksite_to_network_file_network_file_id_worksite_id", + "unique": false, + "columnNames": [ + "network_file_id", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_to_network_file_network_file_id_worksite_id` ON `${TABLE_NAME}` (`network_file_id`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "network_file_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_file_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, `rotate_degrees` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_network_file_local_images_is_deleted", + "unique": false, + "columnNames": [ + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_network_file_local_images_is_deleted` ON `${TABLE_NAME}` (`is_deleted`)" + } + ], + "foreignKeys": [ + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `worksite_id` INTEGER NOT NULL, `local_document_id` TEXT NOT NULL, `uri` TEXT NOT NULL, `tag` TEXT NOT NULL, `rotate_degrees` INTEGER NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "local_document_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_local_images_worksite_id_local_document_id", + "unique": true, + "columnNames": [ + "worksite_id", + "local_document_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_local_images_worksite_id_local_document_id` ON `${TABLE_NAME}` (`worksite_id`, `local_document_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_worksites_full_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `synced_at` INTEGER, `center_my_location` INTEGER NOT NULL, `center_latitude` REAL NOT NULL DEFAULT 999, `center_longitude` REAL NOT NULL DEFAULT 999, `query_area_radius` REAL NOT NULL, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMyLocationCentered", + "columnName": "center_my_location", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "center_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "longitude", + "columnName": "center_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "radius", + "columnName": "query_area_radius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incidents", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_UPDATE BEFORE UPDATE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_DELETE BEFORE DELETE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_UPDATE AFTER UPDATE ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_INSERT AFTER INSERT ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END" + ], + "tableName": "incident_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', content=`incidents`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incident_organizations", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_UPDATE BEFORE UPDATE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_DELETE BEFORE DELETE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_UPDATE AFTER UPDATE ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_INSERT AFTER INSERT ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ], + "tableName": "incident_organization_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, content=`incident_organizations`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "case_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `created_by` INTEGER NOT NULL, `event_key` TEXT NOT NULL, `past_tense_t` TEXT NOT NULL, `actor_location_name` TEXT NOT NULL, `recipient_location_name` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventKey", + "columnName": "event_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pastTenseT", + "columnName": "past_tense_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorLocationName", + "columnName": "actor_location_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientLocationName", + "columnName": "recipient_location_name", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_case_history_events_worksite_id_created_by_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_by", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_case_history_events_worksite_id_created_by_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_by`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "case_history_event_attrs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_name` TEXT NOT NULL, `patient_case_number` TEXT, `patient_id` INTEGER NOT NULL, `patient_label_t` TEXT, `patient_location_name` TEXT, `patient_name_t` TEXT, `patient_reason_t` TEXT, `patient_status_name_t` TEXT, `recipient_case_number` TEXT, `recipient_id` INTEGER, `recipient_name` TEXT, `recipient_name_t` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `case_history_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentName", + "columnName": "incident_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCaseNumber", + "columnName": "patient_case_number", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientId", + "columnName": "patient_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientLabelT", + "columnName": "patient_label_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientLocationName", + "columnName": "patient_location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientNameT", + "columnName": "patient_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientReasonT", + "columnName": "patient_reason_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientStatusNameT", + "columnName": "patient_status_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientCaseNumber", + "columnName": "recipient_case_number", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientId", + "columnName": "recipient_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recipientName", + "columnName": "recipient_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientNameT", + "columnName": "recipient_name_t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "case_history_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "person_to_organization", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `organization_id`), FOREIGN KEY(`id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "organization_id" + ] + }, + "indices": [ + { + "name": "index_person_to_organization_organization_id_id", + "unique": false, + "columnNames": [ + "organization_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_person_to_organization_organization_id_id` ON `${TABLE_NAME}` (`organization_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "worksites", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_BEFORE_UPDATE BEFORE UPDATE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_BEFORE_DELETE BEFORE DELETE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_UPDATE AFTER UPDATE ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_INSERT AFTER INSERT ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END" + ], + "tableName": "worksite_text_fts_b", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`address` TEXT NOT NULL, `case_number` TEXT NOT NULL, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `email` TEXT NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT NOT NULL, content=`worksites`)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "incident_worksites_secondary_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "updatedBy", + "columnName": "updated_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "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", + "notNull": false + } + ], + "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" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '23e4bda629525238defdb9a682b03d81')" + ] + } +} \ No newline at end of file diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/42.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/42.json new file mode 100644 index 000000000..9d414f9cd --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/42.json @@ -0,0 +1,3006 @@ +{ + "formatVersion": 1, + "database": { + "version": 42, + "identityHash": "dc5998d46b69b5060dd723d8675a4bf6", + "entities": [ + { + "tableName": "work_type_statuses", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`status` TEXT NOT NULL, `name` TEXT NOT NULL, `list_order` INTEGER NOT NULL, `primary_state` TEXT NOT NULL, PRIMARY KEY(`status`))", + "fields": [ + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "primaryState", + "columnName": "primary_state", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "status" + ] + }, + "indices": [ + { + "name": "index_work_type_statuses_list_order", + "unique": false, + "columnNames": [ + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_type_statuses_list_order` ON `${TABLE_NAME}` (`list_order`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "incidents", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `start_at` INTEGER NOT NULL, `name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `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, 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", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "turnOnRelease", + "columnName": "turn_on_release", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isArchived", + "columnName": "is_archived", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "idx_newest_to_oldest_incidents", + "unique": false, + "columnNames": [ + "start_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_newest_to_oldest_incidents` ON `${TABLE_NAME}` (`start_at` DESC)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "incident_locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `location` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "incident_to_incident_location", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `incident_location_id` INTEGER NOT NULL, PRIMARY KEY(`incident_id`, `incident_location_id`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`incident_location_id`) REFERENCES `incident_locations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentLocationId", + "columnName": "incident_location_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "incident_location_id" + ] + }, + "indices": [ + { + "name": "idx_incident_location_to_incident", + "unique": false, + "columnNames": [ + "incident_location_id", + "incident_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_incident_location_to_incident` ON `${TABLE_NAME}` (`incident_location_id`, `incident_id`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_locations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_location_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_form_fields", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `label` TEXT NOT NULL, `html_type` TEXT NOT NULL, `data_group` TEXT NOT NULL, `help` TEXT DEFAULT '', `placeholder` TEXT DEFAULT '', `read_only_break_glass` INTEGER NOT NULL, `values_default_json` TEXT DEFAULT '', `is_checkbox_default_true` INTEGER DEFAULT 0, `order_label` INTEGER NOT NULL DEFAULT -1, `validation` TEXT DEFAULT '', `recur_default` TEXT DEFAULT '0', `values_json` TEXT DEFAULT '', `is_required` INTEGER DEFAULT 0, `is_read_only` INTEGER DEFAULT 0, `list_order` INTEGER NOT NULL, `is_invalidated` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `field_parent_key` TEXT DEFAULT '', `parent_key` TEXT NOT NULL DEFAULT '', `selected_toggle_work_type` TEXT DEFAULT '', PRIMARY KEY(`incident_id`, `parent_key`, `field_key`), FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "label", + "columnName": "label", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "htmlType", + "columnName": "html_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dataGroup", + "columnName": "data_group", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "help", + "columnName": "help", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "placeholder", + "columnName": "placeholder", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "readOnlyBreakGlass", + "columnName": "read_only_break_glass", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valuesDefaultJson", + "columnName": "values_default_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isCheckboxDefaultTrue", + "columnName": "is_checkbox_default_true", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "orderLabel", + "columnName": "order_label", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "validation", + "columnName": "validation", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "recurDefault", + "columnName": "recur_default", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "'0'" + }, + { + "fieldPath": "valuesJson", + "columnName": "values_json", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "isRequired", + "columnName": "is_required", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "isReadOnly", + "columnName": "is_read_only", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isInvalidated", + "columnName": "is_invalidated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fieldParentKey", + "columnName": "field_parent_key", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "parentKeyNonNull", + "columnName": "parent_key", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "selectToggleWorkType", + "columnName": "selected_toggle_work_type", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id", + "parent_key", + "field_key" + ] + }, + "indices": [ + { + "name": "index_incident_form_fields_data_group_parent_key_list_order", + "unique": false, + "columnNames": [ + "data_group", + "parent_key", + "list_order" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_incident_form_fields_data_group_parent_key_list_order` ON `${TABLE_NAME}` (`data_group`, `parent_key`, `list_order`)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "locations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `shape_type` TEXT NOT NULL DEFAULT '', `coordinates` TEXT NOT NULL DEFAULT '', PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shapeType", + "columnName": "shape_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "coordinates", + "columnName": "coordinates", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksite_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksites_root", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `local_modified_at` INTEGER NOT NULL DEFAULT 0, `synced_at` INTEGER NOT NULL DEFAULT 0, `local_global_uuid` TEXT NOT NULL DEFAULT '', `is_local_modified` INTEGER NOT NULL DEFAULT 0, `sync_attempt` INTEGER NOT NULL DEFAULT 0, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, FOREIGN KEY(`incident_id`) REFERENCES `incidents`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "localModifiedAt", + "columnName": "local_modified_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "isLocalModified", + "columnName": "is_local_modified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "syncAttempt", + "columnName": "sync_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_root_network_id_local_global_uuid", + "unique": true, + "columnNames": [ + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksites_root_network_id_local_global_uuid` ON `${TABLE_NAME}` (`network_id`, `local_global_uuid`)" + }, + { + "name": "index_worksites_root_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_root_is_local_modified_local_modified_at", + "unique": false, + "columnNames": [ + "is_local_modified", + "local_modified_at" + ], + "orders": [ + "DESC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_root_is_local_modified_local_modified_at` ON `${TABLE_NAME}` (`is_local_modified` DESC, `local_modified_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "incidents", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `incident_id` INTEGER NOT NULL, `address` TEXT NOT NULL, `auto_contact_frequency_t` TEXT, `case_number` TEXT NOT NULL, `case_number_order` INTEGER NOT NULL DEFAULT 0, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `created_at` INTEGER, `email` TEXT DEFAULT '', `favorite_id` INTEGER, `key_work_type_type` TEXT NOT NULL DEFAULT '', `key_work_type_org` INTEGER, `key_work_type_status` TEXT NOT NULL DEFAULT '', `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT, `phone2` TEXT DEFAULT '', `plus_code` TEXT DEFAULT '', `postal_code` TEXT NOT NULL, `reported_by` INTEGER, `state` TEXT NOT NULL, `svi` REAL, `what3Words` TEXT DEFAULT '', `updated_at` INTEGER NOT NULL, `is_local_favorite` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "autoContactFrequencyT", + "columnName": "auto_contact_frequency_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumberOrder", + "columnName": "case_number_order", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "favoriteId", + "columnName": "favorite_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyWorkTypeType", + "columnName": "key_work_type_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "keyWorkTypeOrgClaim", + "columnName": "key_work_type_org", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "keyWorkTypeStatus", + "columnName": "key_work_type_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "plusCode", + "columnName": "plus_code", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "postalCode", + "columnName": "postal_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reportedBy", + "columnName": "reported_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "svi", + "columnName": "svi", + "affinity": "REAL", + "notNull": false + }, + { + "fieldPath": "what3Words", + "columnName": "what3Words", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isLocalFavorite", + "columnName": "is_local_favorite", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksites_incident_id_network_id", + "unique": false, + "columnNames": [ + "incident_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_network_id` ON `${TABLE_NAME}` (`incident_id`, `network_id`)" + }, + { + "name": "index_worksites_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksites_incident_id_latitude_longitude", + "unique": false, + "columnNames": [ + "incident_id", + "latitude", + "longitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_latitude_longitude` ON `${TABLE_NAME}` (`incident_id`, `latitude`, `longitude`)" + }, + { + "name": "index_worksites_incident_id_longitude_latitude", + "unique": false, + "columnNames": [ + "incident_id", + "longitude", + "latitude" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_longitude_latitude` ON `${TABLE_NAME}` (`incident_id`, `longitude`, `latitude`)" + }, + { + "name": "index_worksites_incident_id_svi", + "unique": false, + "columnNames": [ + "incident_id", + "svi" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_svi` ON `${TABLE_NAME}` (`incident_id`, `svi`)" + }, + { + "name": "index_worksites_incident_id_updated_at", + "unique": false, + "columnNames": [ + "incident_id", + "updated_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_updated_at` ON `${TABLE_NAME}` (`incident_id`, `updated_at`)" + }, + { + "name": "index_worksites_incident_id_created_at", + "unique": false, + "columnNames": [ + "incident_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_created_at` ON `${TABLE_NAME}` (`incident_id`, `created_at`)" + }, + { + "name": "index_worksites_incident_id_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_name_county_city_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "name", + "county", + "city", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_name_county_city_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `name`, `county`, `city`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_city_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "city", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_city_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `city`, `name`, `case_number_order`, `case_number`)" + }, + { + "name": "index_worksites_incident_id_county_name_case_number_order_case_number", + "unique": false, + "columnNames": [ + "incident_id", + "county", + "name", + "case_number_order", + "case_number" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksites_incident_id_county_name_case_number_order_case_number` ON `${TABLE_NAME}` (`incident_id`, `county`, `name`, `case_number_order`, `case_number`)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "work_types", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER, `claimed_by` INTEGER, `next_recur_at` INTEGER, `phase` INTEGER, `recur` TEXT, `status` TEXT NOT NULL, `work_type` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "orgClaim", + "columnName": "claimed_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "nextRecurAt", + "columnName": "next_recur_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "phase", + "columnName": "phase", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recur", + "columnName": "recur", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_work_type", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_work_type` ON `${TABLE_NAME}` (`worksite_id`, `work_type`)" + }, + { + "name": "index_work_types_worksite_id_network_id", + "unique": false, + "columnNames": [ + "worksite_id", + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_worksite_id_network_id` ON `${TABLE_NAME}` (`worksite_id`, `network_id`)" + }, + { + "name": "index_work_types_status_worksite_id", + "unique": false, + "columnNames": [ + "status", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_status_worksite_id` ON `${TABLE_NAME}` (`status`, `worksite_id`)" + }, + { + "name": "index_work_types_claimed_by_worksite_id", + "unique": false, + "columnNames": [ + "claimed_by", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_work_types_claimed_by_worksite_id` ON `${TABLE_NAME}` (`claimed_by`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_form_data", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `field_key` TEXT NOT NULL, `is_bool_value` INTEGER NOT NULL, `value_string` TEXT NOT NULL, `value_bool` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `field_key`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fieldKey", + "columnName": "field_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isBoolValue", + "columnName": "is_bool_value", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "valueString", + "columnName": "value_string", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "valueBool", + "columnName": "value_bool", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "field_key" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_flags", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `action` TEXT, `created_at` INTEGER NOT NULL, `is_high_priority` INTEGER DEFAULT 0, `notes` TEXT DEFAULT '', `reason_t` TEXT NOT NULL, `requested_action` TEXT DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "action", + "columnName": "action", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isHighPriority", + "columnName": "is_high_priority", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + }, + { + "fieldPath": "reasonT", + "columnName": "reason_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestedAction", + "columnName": "requested_action", + "affinity": "TEXT", + "notNull": false, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_flag", + "unique": true, + "columnNames": [ + "worksite_id", + "reason_t" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_flag` ON `${TABLE_NAME}` (`worksite_id`, `reason_t`)" + }, + { + "name": "index_worksite_flags_reason_t", + "unique": false, + "columnNames": [ + "reason_t" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_flags_reason_t` ON `${TABLE_NAME}` (`reason_t`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_notes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `local_global_uuid` TEXT NOT NULL DEFAULT '', `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `is_survivor` INTEGER NOT NULL, `note` TEXT NOT NULL DEFAULT '', FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "localGlobalUuid", + "columnName": "local_global_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isSurvivor", + "columnName": "is_survivor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "note", + "columnName": "note", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "unique_worksite_note", + "unique": true, + "columnNames": [ + "worksite_id", + "network_id", + "local_global_uuid" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `unique_worksite_note` ON `${TABLE_NAME}` (`worksite_id`, `network_id`, `local_global_uuid`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "language_translations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `name` TEXT NOT NULL, `translations_json` TEXT, `synced_at` INTEGER DEFAULT 0, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "translationsJson", + "columnName": "translations_json", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": false, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "sync_logs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `log_time` INTEGER NOT NULL, `log_type` TEXT NOT NULL DEFAULT '', `message` TEXT NOT NULL, `details` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logTime", + "columnName": "log_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "logType", + "columnName": "log_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "details", + "columnName": "details", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sync_logs_log_time", + "unique": false, + "columnNames": [ + "log_time" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sync_logs_log_time` ON `${TABLE_NAME}` (`log_time` DESC)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "worksite_changes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `app_version` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `sync_uuid` TEXT NOT NULL DEFAULT '', `change_model_version` INTEGER NOT NULL, `change_data` TEXT NOT NULL, `created_at` INTEGER NOT NULL, `save_attempt` INTEGER NOT NULL DEFAULT 0, `archive_action` TEXT NOT NULL, `save_attempt_at` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`worksite_id`) REFERENCES `worksites_root`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appVersion", + "columnName": "app_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncUuid", + "columnName": "sync_uuid", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "changeModelVersion", + "columnName": "change_model_version", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "changeData", + "columnName": "change_data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "saveAttempt", + "columnName": "save_attempt", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "archiveAction", + "columnName": "archive_action", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "saveAttemptAt", + "columnName": "save_attempt_at", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_changes_worksite_id_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_at`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt` ON `${TABLE_NAME}` (`worksite_id`, `save_attempt`)" + }, + { + "name": "index_worksite_changes_worksite_id_save_attempt_at_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "save_attempt_at", + "created_at" + ], + "orders": [ + "ASC", + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_changes_worksite_id_save_attempt_at_created_at` ON `${TABLE_NAME}` (`worksite_id` ASC, `save_attempt_at` ASC, `created_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites_root", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organizations", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `primary_location` INTEGER, `secondary_location` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "primaryLocation", + "columnName": "primary_location", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "secondaryLocation", + "columnName": "secondary_location", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "person_contacts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `first_name` TEXT NOT NULL, `last_name` TEXT NOT NULL, `email` TEXT NOT NULL, `mobile` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "firstName", + "columnName": "first_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastName", + "columnName": "last_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mobile", + "columnName": "mobile", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "organization_to_primary_contact", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`organization_id` INTEGER NOT NULL, `contact_id` INTEGER NOT NULL, PRIMARY KEY(`organization_id`, `contact_id`), FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`contact_id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contactId", + "columnName": "contact_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "organization_id", + "contact_id" + ] + }, + "indices": [ + { + "name": "idx_contact_to_organization", + "unique": false, + "columnNames": [ + "contact_id", + "organization_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `idx_contact_to_organization` ON `${TABLE_NAME}` (`contact_id`, `organization_id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "contact_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "organization_to_affiliate", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `affiliate_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `affiliate_id`), FOREIGN KEY(`id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "affiliateId", + "columnName": "affiliate_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "affiliate_id" + ] + }, + "indices": [ + { + "name": "index_organization_to_affiliate_affiliate_id_id", + "unique": false, + "columnNames": [ + "affiliate_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_organization_to_affiliate_affiliate_id_id` ON `${TABLE_NAME}` (`affiliate_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_organization_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `target_count` INTEGER NOT NULL, `successful_sync` INTEGER, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`))", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "recent_worksites", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_id` INTEGER NOT NULL, `viewed_at` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viewedAt", + "columnName": "viewed_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_recent_worksites_incident_id_viewed_at", + "unique": false, + "columnNames": [ + "incident_id", + "viewed_at" + ], + "orders": [ + "ASC", + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_incident_id_viewed_at` ON `${TABLE_NAME}` (`incident_id` ASC, `viewed_at` DESC)" + }, + { + "name": "index_recent_worksites_viewed_at", + "unique": false, + "columnNames": [ + "viewed_at" + ], + "orders": [ + "DESC" + ], + "createSql": "CREATE INDEX IF NOT EXISTS `index_recent_worksites_viewed_at` ON `${TABLE_NAME}` (`viewed_at` DESC)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_work_type_requests", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `network_id` INTEGER NOT NULL DEFAULT -1, `worksite_id` INTEGER NOT NULL, `work_type` TEXT NOT NULL, `reason` TEXT NOT NULL, `by_org` INTEGER NOT NULL, `to_org` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `approved_at` INTEGER, `rejected_at` INTEGER, `approved_rejected_reason` TEXT NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkId", + "columnName": "network_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workType", + "columnName": "work_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "reason", + "columnName": "reason", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "byOrg", + "columnName": "by_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "toOrg", + "columnName": "to_org", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "approvedAt", + "columnName": "approved_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "rejectedAt", + "columnName": "rejected_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "approvedRejectedReason", + "columnName": "approved_rejected_reason", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_work_type_requests_worksite_id_work_type_by_org", + "unique": true, + "columnNames": [ + "worksite_id", + "work_type", + "by_org" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_work_type_by_org` ON `${TABLE_NAME}` (`worksite_id`, `work_type`, `by_org`)" + }, + { + "name": "index_worksite_work_type_requests_network_id", + "unique": false, + "columnNames": [ + "network_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_network_id` ON `${TABLE_NAME}` (`network_id`)" + }, + { + "name": "index_worksite_work_type_requests_worksite_id_by_org", + "unique": false, + "columnNames": [ + "worksite_id", + "by_org" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_work_type_requests_worksite_id_by_org` ON `${TABLE_NAME}` (`worksite_id`, `by_org`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_files", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `file_id` INTEGER NOT NULL DEFAULT 0, `file_type_t` TEXT NOT NULL, `full_url` TEXT, `large_thumbnail_url` TEXT, `mime_content_type` TEXT NOT NULL, `small_thumbnail_url` TEXT, `tag` TEXT, `title` TEXT, `url` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "fileId", + "columnName": "file_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fileTypeT", + "columnName": "file_type_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fullUrl", + "columnName": "full_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "largeThumbnailUrl", + "columnName": "large_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "mimeContentType", + "columnName": "mime_content_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "smallThumbnailUrl", + "columnName": "small_thumbnail_url", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "url", + "columnName": "url", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "worksite_to_network_file", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`worksite_id` INTEGER NOT NULL, `network_file_id` INTEGER NOT NULL, PRIMARY KEY(`worksite_id`, `network_file_id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`network_file_id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "networkFileId", + "columnName": "network_file_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "worksite_id", + "network_file_id" + ] + }, + "indices": [ + { + "name": "index_worksite_to_network_file_network_file_id_worksite_id", + "unique": false, + "columnNames": [ + "network_file_id", + "worksite_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_worksite_to_network_file_network_file_id_worksite_id` ON `${TABLE_NAME}` (`network_file_id`, `worksite_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "network_file_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "network_file_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `is_deleted` INTEGER NOT NULL, `rotate_degrees` INTEGER NOT NULL, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `network_files`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_network_file_local_images_is_deleted", + "unique": false, + "columnNames": [ + "is_deleted" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_network_file_local_images_is_deleted` ON `${TABLE_NAME}` (`is_deleted`)" + } + ], + "foreignKeys": [ + { + "table": "network_files", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "worksite_local_images", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `worksite_id` INTEGER NOT NULL, `local_document_id` TEXT NOT NULL, `uri` TEXT NOT NULL, `tag` TEXT NOT NULL, `rotate_degrees` INTEGER NOT NULL, FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "documentId", + "columnName": "local_document_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "uri", + "columnName": "uri", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "rotateDegrees", + "columnName": "rotate_degrees", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_worksite_local_images_worksite_id_local_document_id", + "unique": true, + "columnNames": [ + "worksite_id", + "local_document_id" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_worksite_local_images_worksite_id_local_document_id` ON `${TABLE_NAME}` (`worksite_id`, `local_document_id`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "incident_worksites_full_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `synced_at` INTEGER, `center_my_location` INTEGER NOT NULL, `center_latitude` REAL NOT NULL DEFAULT 999, `center_longitude` REAL NOT NULL DEFAULT 999, `query_area_radius` REAL NOT NULL, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncedAt", + "columnName": "synced_at", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "isMyLocationCentered", + "columnName": "center_my_location", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "center_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "longitude", + "columnName": "center_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "999" + }, + { + "fieldPath": "radius", + "columnName": "query_area_radius", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incidents", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_UPDATE BEFORE UPDATE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_BEFORE_DELETE BEFORE DELETE ON `incidents` BEGIN DELETE FROM `incident_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_UPDATE AFTER UPDATE ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_fts_AFTER_INSERT AFTER INSERT ON `incidents` BEGIN INSERT INTO `incident_fts`(`docid`, `name`, `short_name`, `incident_type`) VALUES (NEW.`rowid`, NEW.`name`, NEW.`short_name`, NEW.`incident_type`); END" + ], + "tableName": "incident_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, `short_name` TEXT NOT NULL DEFAULT '', `incident_type` TEXT NOT NULL DEFAULT '', content=`incidents`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "type", + "columnName": "incident_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "incident_organizations", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_UPDATE BEFORE UPDATE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_BEFORE_DELETE BEFORE DELETE ON `incident_organizations` BEGIN DELETE FROM `incident_organization_fts` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_UPDATE AFTER UPDATE ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_incident_organization_fts_AFTER_INSERT AFTER INSERT ON `incident_organizations` BEGIN INSERT INTO `incident_organization_fts`(`docid`, `name`) VALUES (NEW.`rowid`, NEW.`name`); END" + ], + "tableName": "incident_organization_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`name` TEXT NOT NULL, content=`incident_organizations`)", + "fields": [ + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "case_history_events", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `worksite_id` INTEGER NOT NULL, `created_at` INTEGER NOT NULL, `created_by` INTEGER NOT NULL, `event_key` TEXT NOT NULL, `past_tense_t` TEXT NOT NULL, `actor_location_name` TEXT NOT NULL, `recipient_location_name` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`worksite_id`) REFERENCES `worksites`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "worksiteId", + "columnName": "worksite_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createdBy", + "columnName": "created_by", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "eventKey", + "columnName": "event_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pastTenseT", + "columnName": "past_tense_t", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "actorLocationName", + "columnName": "actor_location_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "recipientLocationName", + "columnName": "recipient_location_name", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_case_history_events_worksite_id_created_by_created_at", + "unique": false, + "columnNames": [ + "worksite_id", + "created_by", + "created_at" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_case_history_events_worksite_id_created_by_created_at` ON `${TABLE_NAME}` (`worksite_id`, `created_by`, `created_at`)" + } + ], + "foreignKeys": [ + { + "table": "worksites", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "worksite_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "case_history_event_attrs", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `incident_name` TEXT NOT NULL, `patient_case_number` TEXT, `patient_id` INTEGER NOT NULL, `patient_label_t` TEXT, `patient_location_name` TEXT, `patient_name_t` TEXT, `patient_reason_t` TEXT, `patient_status_name_t` TEXT, `recipient_case_number` TEXT, `recipient_id` INTEGER, `recipient_name` TEXT, `recipient_name_t` TEXT, PRIMARY KEY(`id`), FOREIGN KEY(`id`) REFERENCES `case_history_events`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "incidentName", + "columnName": "incident_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "patientCaseNumber", + "columnName": "patient_case_number", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientId", + "columnName": "patient_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "patientLabelT", + "columnName": "patient_label_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientLocationName", + "columnName": "patient_location_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientNameT", + "columnName": "patient_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientReasonT", + "columnName": "patient_reason_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "patientStatusNameT", + "columnName": "patient_status_name_t", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientCaseNumber", + "columnName": "recipient_case_number", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientId", + "columnName": "recipient_id", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "recipientName", + "columnName": "recipient_name", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "recipientNameT", + "columnName": "recipient_name_t", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "case_history_events", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "person_to_organization", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `organization_id` INTEGER NOT NULL, PRIMARY KEY(`id`, `organization_id`), FOREIGN KEY(`id`) REFERENCES `person_contacts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`organization_id`) REFERENCES `incident_organizations`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "organizationId", + "columnName": "organization_id", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id", + "organization_id" + ] + }, + "indices": [ + { + "name": "index_person_to_organization_organization_id_id", + "unique": false, + "columnNames": [ + "organization_id", + "id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_person_to_organization_organization_id_id` ON `${TABLE_NAME}` (`organization_id`, `id`)" + } + ], + "foreignKeys": [ + { + "table": "person_contacts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "id" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "incident_organizations", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "organization_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "ftsVersion": "FTS4", + "ftsOptions": { + "tokenizer": "simple", + "tokenizerArgs": [], + "contentTable": "worksites", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_BEFORE_UPDATE BEFORE UPDATE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_BEFORE_DELETE BEFORE DELETE ON `worksites` BEGIN DELETE FROM `worksite_text_fts_b` WHERE `docid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_UPDATE AFTER UPDATE ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_worksite_text_fts_b_AFTER_INSERT AFTER INSERT ON `worksites` BEGIN INSERT INTO `worksite_text_fts_b`(`docid`, `address`, `case_number`, `city`, `county`, `email`, `name`, `phone1`, `phone2`) VALUES (NEW.`rowid`, NEW.`address`, NEW.`case_number`, NEW.`city`, NEW.`county`, NEW.`email`, NEW.`name`, NEW.`phone1`, NEW.`phone2`); END" + ], + "tableName": "worksite_text_fts_b", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS4(`address` TEXT NOT NULL, `case_number` TEXT NOT NULL, `city` TEXT NOT NULL, `county` TEXT NOT NULL, `email` TEXT NOT NULL, `name` TEXT NOT NULL, `phone1` TEXT NOT NULL, `phone2` TEXT NOT NULL, content=`worksites`)", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "caseNumber", + "columnName": "case_number", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "city", + "columnName": "city", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "county", + "columnName": "county", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "email", + "columnName": "email", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone1", + "columnName": "phone1", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phone2", + "columnName": "phone2", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "incident_worksites_secondary_sync_stats", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`incident_id` INTEGER NOT NULL, `sync_start` INTEGER NOT NULL DEFAULT 0, `target_count` INTEGER NOT NULL, `paged_count` INTEGER NOT NULL DEFAULT 0, `successful_sync` INTEGER, `attempted_sync` INTEGER, `attempted_counter` INTEGER NOT NULL, `app_build_version_code` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`incident_id`), FOREIGN KEY(`incident_id`) REFERENCES `worksite_sync_stats`(`incident_id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "incidentId", + "columnName": "incident_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "syncStart", + "columnName": "sync_start", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "targetCount", + "columnName": "target_count", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "pagedCount", + "columnName": "paged_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "successfulSync", + "columnName": "successful_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedSync", + "columnName": "attempted_sync", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "attemptedCounter", + "columnName": "attempted_counter", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "appBuildVersionCode", + "columnName": "app_build_version_code", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "incident_id" + ] + }, + "indices": [], + "foreignKeys": [ + { + "table": "worksite_sync_stats", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "incident_id" + ], + "referencedColumns": [ + "incident_id" + ] + } + ] + }, + { + "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", + "notNull": false + }, + { + "fieldPath": "updatedBy", + "columnName": "updated_by", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "parent", + "columnName": "parent", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "listOrder", + "columnName": "list_order", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT", + "notNull": false + }, + { + "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", + "notNull": false + } + ], + "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" + ] + } + ] + } + ], + "views": [], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'dc5998d46b69b5060dd723d8675a4bf6')" + ] + } +} \ 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 319ed6321..5856d360a 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 @@ -19,6 +19,7 @@ import com.crisiscleanup.core.database.model.IncidentOrganizationSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.LanguageTranslationEntity +import com.crisiscleanup.core.database.model.ListEntity import com.crisiscleanup.core.database.model.LocationEntity import com.crisiscleanup.core.database.model.NetworkFileEntity import com.crisiscleanup.core.database.model.NetworkFileLocalImageEntity @@ -83,6 +84,7 @@ import kotlinx.datetime.Instant PersonOrganizationCrossRef::class, WorksiteTextFtsEntity::class, IncidentWorksitesSecondarySyncStatsEntity::class, + ListEntity::class, ], version = 1, ) diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestUtil.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestUtil.kt index 3d6c97e44..9aac6740b 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestUtil.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/TestUtil.kt @@ -19,50 +19,62 @@ import kotlin.time.Duration.Companion.seconds object TestUtil { // TODO Change spys to mock when https://github.com/mockk/mockk/issues/1035 is fixed - fun testUuidGenerator(): UuidGenerator = spyk(object : UuidGenerator { - private val counter = AtomicInteger() - override fun uuid() = "uuid-${counter.incrementAndGet()}" - }) + fun testUuidGenerator(): UuidGenerator = spyk( + object : UuidGenerator { + private val counter = AtomicInteger() + override fun uuid() = "uuid-${counter.incrementAndGet()}" + }, + ) fun testChangeSerializer(): WorksiteChangeSerializer = - spyk(object : WorksiteChangeSerializer { - override fun serialize( - isDataChange: Boolean, - worksiteStart: Worksite, - worksiteChange: Worksite, - flagIdLookup: Map, - noteIdLookup: Map, - workTypeIdLookup: Map, - requestReason: String, - requestWorkTypes: List, - releaseReason: String, - releaseWorkTypes: List, - ) = Pair(1, "test-worksite-change") - }) - - fun testAppVersionProvider(): AppVersionProvider = spyk(object : AppVersionProvider { - override val version: Pair = Pair(81, "1.0.81") - override val versionCode: Long = version.first - override val versionName: String = version.second - }) - - fun testAppLogger(): AppLogger = spyk(object : AppLogger { - override fun logDebug(vararg logs: Any) {} - - override fun logException(e: Exception) {} - - override fun logCapture(message: String) {} - }) - - fun testSyncLogger(): SyncLogger = spyk(object : SyncLogger { - override var type: String = "test" - - override fun log(message: String, details: String, type: String) = this - - override fun clear() = this - - override fun flush() {} - }) + spyk( + object : WorksiteChangeSerializer { + override fun serialize( + isDataChange: Boolean, + worksiteStart: Worksite, + worksiteChange: Worksite, + flagIdLookup: Map, + noteIdLookup: Map, + workTypeIdLookup: Map, + requestReason: String, + requestWorkTypes: List, + releaseReason: String, + releaseWorkTypes: List, + ) = Pair(1, "test-worksite-change") + }, + ) + + fun testAppVersionProvider(): AppVersionProvider = spyk( + object : AppVersionProvider { + override val version: Pair = Pair(81, "1.0.81") + override val versionCode: Long = version.first + override val versionName: String = version.second + }, + ) + + fun testAppLogger(): AppLogger = spyk( + object : AppLogger { + override fun logDebug(vararg logs: Any) {} + + override fun logException(e: Exception) {} + + override fun logCapture(message: String) {} + + override fun setAccountId(id: String) {} + }, + ) + + fun testSyncLogger(): SyncLogger = spyk( + object : SyncLogger { + override var type: String = "test" + + override fun log(message: String, details: String, type: String) = this + + override fun clear() = this + + override fun flush() {} + }, + ) fun getDatabase(): CrisisCleanupDatabase { val context = ApplicationProvider.getApplicationContext() diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt index f97dde166..4ad3d8fdd 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/IncidentDaoTest.kt @@ -130,6 +130,7 @@ class IncidentDaoTest { activePhoneNumber = phoneNumber, name = "", shortName = "", + caseLabel = "", type = "", ) @@ -160,6 +161,7 @@ class IncidentDaoTest { id, "", "", + "", emptyList(), phoneNumbers, emptyList(), @@ -267,4 +269,11 @@ class IncidentDaoTest { fun testIncidentEntity( id: Long, startAtSeconds: Long, -) = IncidentEntity(id, Instant.fromEpochSeconds(startAtSeconds), "", "", "") +) = IncidentEntity( + id, + Instant.fromEpochSeconds(startAtSeconds), + "", + "", + "", + "", +) diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeDaoTest.kt index 070b09e37..5beff3377 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeDaoTest.kt @@ -2,6 +2,7 @@ package com.crisiscleanup.core.database.dao import com.crisiscleanup.core.database.TestCrisisCleanupDatabase import com.crisiscleanup.core.database.TestUtil +import com.crisiscleanup.core.database.TestUtil.testAppLogger import com.crisiscleanup.core.database.TestUtil.testSyncLogger import com.crisiscleanup.core.database.WorksiteTestUtil import com.crisiscleanup.core.database.model.WorkTypeEntity @@ -22,6 +23,7 @@ class WorkTypeDaoTest { private lateinit var workTypeDaoPlus: WorkTypeDaoPlus private val syncLogger = testSyncLogger() + private val appLogger = testAppLogger() private val now = Clock.System.now() private val updatedA = now.plus((-9999).seconds) @@ -30,7 +32,7 @@ class WorkTypeDaoTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) workTypeDao = db.workTypeDao() workTypeDaoPlus = WorkTypeDaoPlus(db) } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeTransferRequestDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeTransferRequestDaoTest.kt index 8993492d8..a7c5738c5 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeTransferRequestDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorkTypeTransferRequestDaoTest.kt @@ -23,6 +23,7 @@ class WorkTypeTransferRequestDaoTest { private lateinit var requestDaoPlus: WorkTypeTransferRequestDaoPlus private val syncLogger = TestUtil.testSyncLogger() + private val appLogger = TestUtil.testAppLogger() private val createdAtA = now.plus((-9999).seconds) private val updatedAtA = createdAtA.plus((451).seconds) @@ -31,7 +32,7 @@ class WorkTypeTransferRequestDaoTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) requestDao = db.workTypeTransferRequestDao() requestDaoPlus = WorkTypeTransferRequestDaoPlus(db) } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeDaoTest.kt index 879d83e20..02f8b50c1 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeDaoTest.kt @@ -247,7 +247,7 @@ class WorksiteChangeDaoTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) worksiteChangeDaoPlus = WorksiteChangeDaoPlus( db, uuidGenerator, diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeTransferDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeTransferDaoTest.kt index 5a9f8fb6a..7cb2dac17 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeTransferDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteChangeTransferDaoTest.kt @@ -125,7 +125,7 @@ class WorksiteChangeTransferDaoTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) worksiteChangeDaoPlus = WorksiteChangeDaoPlus( db, uuidGenerator, @@ -165,7 +165,6 @@ class WorksiteChangeTransferDaoTest { isSurvivor = false, note = "note", ), - ) db.workTypeDao().insertIgnoreWorkType( WorkTypeEntity( diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteDaoTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteDaoTest.kt index 4ec61b190..8f62744a8 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteDaoTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteDaoTest.kt @@ -2,6 +2,7 @@ package com.crisiscleanup.core.database.dao import com.crisiscleanup.core.database.TestCrisisCleanupDatabase import com.crisiscleanup.core.database.TestUtil +import com.crisiscleanup.core.database.TestUtil.testAppLogger import com.crisiscleanup.core.database.TestUtil.testSyncLogger import com.crisiscleanup.core.database.TestWorksiteDao import com.crisiscleanup.core.database.WorksiteTestUtil @@ -26,12 +27,13 @@ class WorksiteDaoTest { private lateinit var worksiteDaoPlus: WorksiteDaoPlus private val syncLogger = testSyncLogger() + private val appLogger = testAppLogger() @Before fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.testWorksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) } @Before @@ -353,6 +355,13 @@ class WorksiteDaoTest { } // TODO Sync existing worksite where the incident changes. Change back as well? + + // Deterministic result for getWorksiteId when unknown worksite ID is provided + @Test + fun getNonExistingWorksiteId() = runTest { + val worksiteId = db.worksiteDao().getWorksiteId(62525) + assertEquals(0, worksiteId) + } } fun testWorksiteEntity( diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteFormDataFlagNoteTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteFormDataFlagNoteTest.kt index b190eb417..1b77f35ad 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteFormDataFlagNoteTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteFormDataFlagNoteTest.kt @@ -30,6 +30,7 @@ class WorksiteFormDataFlagNoteTest { private lateinit var testWorksiteDao: TestWorksiteDao private val syncLogger = testSyncLogger() + private val appLogger = TestUtil.testAppLogger() private suspend fun insertWorksites( worksites: List, @@ -44,7 +45,7 @@ class WorksiteFormDataFlagNoteTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) testWorksiteDao = db.testWorksiteDao() } diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncFillTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncFillTest.kt index 8aae73f78..911176815 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncFillTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteSyncFillTest.kt @@ -33,7 +33,7 @@ class WorksiteSyncFillTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, TestUtil.testSyncLogger()) + worksiteDaoPlus = WorksiteDaoPlus(db, TestUtil.testSyncLogger(), TestUtil.testAppLogger()) flagDao = db.worksiteFlagDao() formDataDao = db.worksiteFormDataDao() noteDao = db.worksiteNoteDao() diff --git a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteWorkTypeTest.kt b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteWorkTypeTest.kt index cb598262d..8ce757891 100644 --- a/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteWorkTypeTest.kt +++ b/core/database/src/androidTest/java/com/crisiscleanup/core/database/dao/WorksiteWorkTypeTest.kt @@ -2,6 +2,7 @@ package com.crisiscleanup.core.database.dao import com.crisiscleanup.core.database.TestCrisisCleanupDatabase import com.crisiscleanup.core.database.TestUtil +import com.crisiscleanup.core.database.TestUtil.testAppLogger import com.crisiscleanup.core.database.TestUtil.testSyncLogger import com.crisiscleanup.core.database.WorksiteTestUtil import com.crisiscleanup.core.database.model.WorkTypeEntity @@ -27,6 +28,7 @@ class WorksiteWorkTypeTest { private lateinit var worksiteDaoPlus: WorksiteDaoPlus private val syncLogger = testSyncLogger() + private val appLogger = testAppLogger() private suspend fun insertWorksites( worksites: List, @@ -41,7 +43,7 @@ class WorksiteWorkTypeTest { fun createDb() { db = TestUtil.getTestDatabase() worksiteDao = db.worksiteDao() - worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger) + worksiteDaoPlus = WorksiteDaoPlus(db, syncLogger, appLogger) } @Before 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 e8bb4b44b..b8aa7033b 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 @@ -15,6 +15,7 @@ import com.crisiscleanup.core.database.dao.CaseHistoryDao import com.crisiscleanup.core.database.dao.IncidentDao import com.crisiscleanup.core.database.dao.IncidentOrganizationDao import com.crisiscleanup.core.database.dao.LanguageDao +import com.crisiscleanup.core.database.dao.ListDao import com.crisiscleanup.core.database.dao.LocalImageDao import com.crisiscleanup.core.database.dao.LocationDao import com.crisiscleanup.core.database.dao.NetworkFileDao @@ -44,6 +45,7 @@ import com.crisiscleanup.core.database.model.IncidentOrganizationSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesFullSyncStatsEntity import com.crisiscleanup.core.database.model.IncidentWorksitesSecondarySyncStatsEntity import com.crisiscleanup.core.database.model.LanguageTranslationEntity +import com.crisiscleanup.core.database.model.ListEntity import com.crisiscleanup.core.database.model.LocationEntity import com.crisiscleanup.core.database.model.NetworkFileEntity import com.crisiscleanup.core.database.model.NetworkFileLocalImageEntity @@ -104,8 +106,9 @@ import com.crisiscleanup.core.database.util.InstantConverter PersonOrganizationCrossRef::class, WorksiteTextFtsEntity::class, IncidentWorksitesSecondarySyncStatsEntity::class, + ListEntity::class, ], - version = 40, + version = 42, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -146,6 +149,8 @@ import com.crisiscleanup.core.database.util.InstantConverter AutoMigration(from = 37, to = 38), AutoMigration(from = 38, to = 39), AutoMigration(from = 39, to = 40), + AutoMigration(from = 40, to = 41), + AutoMigration(from = 41, to = 42), ], exportSchema = true, ) @@ -182,4 +187,5 @@ abstract class CrisisCleanupDatabase : abstract fun networkFileDao(): NetworkFileDao abstract fun localImageDao(): LocalImageDao abstract fun caseHistoryDao(): CaseHistoryDao + abstract fun listDao(): ListDao } 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 5c6f99f56..d6082a70e 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 @@ -43,6 +43,10 @@ interface IncidentDao { @Query("SELECT * FROM incidents WHERE start_at>:startAtMillis ORDER BY start_at DESC") fun getIncidents(startAtMillis: Long): List + @Transaction + @Query("SELECT * FROM incidents WHERE id IN(:ids)") + fun getIncidents(ids: Collection): List + @Transaction @Query("SELECT * FROM incidents WHERE id=:id") fun getFormFieldsIncident(id: Long): PopulatedFormFieldsIncident? diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt new file mode 100644 index 000000000..5084db299 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt @@ -0,0 +1,99 @@ +package com.crisiscleanup.core.database.dao + +import androidx.paging.PagingSource +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.crisiscleanup.core.database.model.ListEntity +import com.crisiscleanup.core.database.model.PopulatedList +import kotlinx.coroutines.flow.Flow +import kotlinx.datetime.Instant + +@Dao +interface ListDao { + @Transaction + @Query( + """ + SELECT * + FROM lists + WHERE incident_id=:incidentId + ORDER BY updated_at DESC + """, + ) + fun streamIncidentLists(incidentId: Long): Flow> + + @Transaction + @Query("SELECT COUNT(*) FROM lists WHERE incident_id=:incidentId") + fun getIncidentListCount(incidentId: Long): Int + + @Transaction + @Query("SELECT * FROM lists WHERE id=:id") + fun streamList(id: Long): Flow + + @Transaction + @Query("SELECT * FROM lists WHERE id=:id") + fun getList(id: Long): ListEntity? + + @Transaction + @Query("SELECT * FROM lists WHERE network_id IN(:ids)") + fun getListsByNetworkIds(ids: Collection): List + + @Transaction + @Query( + """ + SELECT * + FROM lists + ORDER BY updated_at DESC + """, + ) + fun pageLists(): PagingSource + + @Transaction + @Query("DELETE FROM lists WHERE id=:id") + fun deleteList(id: Long) + + @Transaction + @Query("DELETE FROM lists WHERE network_id IN(:networkIds)") + fun deleteListsByNetworkIds(networkIds: Set) + + @Transaction + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insertIgnoreList(list: ListEntity): Long + + @Transaction + @Query( + """ + UPDATE lists SET + updated_by = :updatedBy, + updated_at = :updatedAt, + parent = :parent, + name = :name, + description = :description, + list_order = :listOrder, + tags = :tags, + model = :model, + object_ids = :objectIds, + shared = :shared, + permissions = :permissions, + incident_id = :incident + WHERE network_id=:networkId AND local_global_uuid='' + """, + ) + fun syncUpdateList( + networkId: Long, + updatedBy: Long?, + updatedAt: Instant, + parent: Long?, + name: String, + description: String, + listOrder: Long?, + tags: String, + model: String, + objectIds: String, + shared: String, + permissions: String, + incident: Long?, + ) +} diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDaoPlus.kt new file mode 100644 index 000000000..ce2350d3a --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDaoPlus.kt @@ -0,0 +1,43 @@ +package com.crisiscleanup.core.database.dao + +import androidx.room.withTransaction +import com.crisiscleanup.core.database.CrisisCleanupDatabase +import com.crisiscleanup.core.database.model.ListEntity +import javax.inject.Inject + +class ListDaoPlus @Inject constructor( + private val db: CrisisCleanupDatabase, +) { + suspend fun syncUpdateLists( + upsertLists: List, + deleteNetworkIds: Set, + ) = db.withTransaction { + val listDao = db.listDao() + + for (list in upsertLists) { + val insertId = listDao.insertIgnoreList(list) + if (insertId < 0) { + // TODO Do not update where local changes exist and were made after updatedAt + with(list) { + listDao.syncUpdateList( + networkId = networkId, + updatedBy = updatedBy, + updatedAt = updatedAt, + parent = parent, + name = name, + description = description ?: "", + listOrder = listOrder, + tags = tags ?: "", + model = model, + objectIds = objectIds, + shared = shared, + permissions = permissions, + incident = incidentId, + ) + } + } + } + + listDao.deleteListsByNetworkIds(deleteNetworkIds) + } +} diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/PersonContactDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/PersonContactDao.kt index f518030f3..c1708f374 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/PersonContactDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/PersonContactDao.kt @@ -34,4 +34,8 @@ interface PersonContactDao { @Transaction @Query("SELECT * FROM person_contacts WHERE id=:id") fun getContact(id: Long): PopulatedPersonContactOrganization? + + @Transaction + @Query("SELECT * FROM person_contacts WHERE id IN(:ids)") + fun getContacts(ids: Collection): List } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/SyncLogDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/SyncLogDao.kt index 6b08c7522..5d569fb27 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/SyncLogDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/SyncLogDao.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.database.dao +import androidx.paging.PagingSource import androidx.room.Dao import androidx.room.Insert import androidx.room.Query @@ -18,8 +19,8 @@ interface SyncLogDao { fun streamLogCount(): Flow @Transaction - @Query("SELECT * FROM sync_logs ORDER BY log_time DESC LIMIT :limit OFFSET :offset") - fun getSyncLogs(limit: Int = 20, offset: Int = 0): List + @Query("SELECT * FROM sync_logs ORDER BY log_time DESC") + fun pageSyncLogs(): PagingSource @Insert fun insertSyncLogs(logs: Collection) 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 0ce65053c..47252087c 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 @@ -10,6 +10,7 @@ import com.crisiscleanup.core.database.model.BoundedSyncedWorksiteIds import com.crisiscleanup.core.database.model.PopulatedFilterDataWorksite import com.crisiscleanup.core.database.model.PopulatedLocalWorksite import com.crisiscleanup.core.database.model.PopulatedTableDataWorksite +import com.crisiscleanup.core.database.model.PopulatedWorksite import com.crisiscleanup.core.database.model.PopulatedWorksiteFiles import com.crisiscleanup.core.database.model.PopulatedWorksiteMapVisual import com.crisiscleanup.core.database.model.WorksiteEntity @@ -36,6 +37,10 @@ interface WorksiteDao { @Query("SELECT * FROM worksites WHERE id=:id") fun getWorksite(id: Long): PopulatedLocalWorksite + @Transaction + @Query("SELECT * FROM worksites WHERE network_id IN(:networkIds)") + fun getWorksitesByNetworkId(networkIds: Collection): List + @Transaction @Query("SELECT * FROM worksites WHERE id=:id") fun streamLocalWorksite(id: Long): Flow diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt index e4fd68173..399c5b3e2 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/WorksiteDaoPlus.kt @@ -2,6 +2,9 @@ package com.crisiscleanup.core.database.dao import androidx.room.withTransaction import com.crisiscleanup.core.common.haversineDistance +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.common.radians import com.crisiscleanup.core.common.sync.SyncLogger import com.crisiscleanup.core.database.CrisisCleanupDatabase @@ -34,6 +37,7 @@ import kotlin.time.Duration.Companion.days class WorksiteDaoPlus @Inject constructor( internal val db: CrisisCleanupDatabase, private val syncLogger: SyncLogger, + @Logger(CrisisCleanupLoggers.Worksites) private val appLogger: AppLogger, ) { private suspend fun getWorksiteLocalModifiedAt( networkWorksiteIds: Set, @@ -307,13 +311,20 @@ class WorksiteDaoPlus @Inject constructor( val isLocallyModified = modifiedAt?.isLocallyModified ?: false if (!isLocallyModified) { val worksiteId = worksiteDao.getWorksiteId(networkWorksiteId) - val fieldKeys = formData.map(WorksiteFormDataEntity::fieldKey) - formDataDao.deleteUnspecifiedKeys(worksiteId, fieldKeys) - val updatedFormData = formData.map { it.copy(worksiteId = worksiteId) } - formDataDao.upsert(updatedFormData) - - val reportedBy = reportedBys[i] - worksiteDao.syncUpdateAdditionalData(worksiteId, reportedBy) + if (worksiteId > 0) { + val fieldKeys = formData.map(WorksiteFormDataEntity::fieldKey) + formDataDao.deleteUnspecifiedKeys(worksiteId, fieldKeys) + val updatedFormData = formData.map { it.copy(worksiteId = worksiteId) } + formDataDao.upsert(updatedFormData) + + val reportedBy = reportedBys[i] + worksiteDao.syncUpdateAdditionalData(worksiteId, reportedBy) + } else { + val unexpectedMessage = + "Case $networkWorksiteId not locally found when syncing additional data." + syncLogger.log(unexpectedMessage) + appLogger.logException(Exception(unexpectedMessage)) + } } } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/di/DaoModule.kt b/core/database/src/main/java/com/crisiscleanup/core/database/di/DaoModule.kt index fb281105a..731e303e9 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/di/DaoModule.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/di/DaoModule.kt @@ -65,4 +65,7 @@ object DaoModule { @Provides fun caseHistoryDao(db: CrisisCleanupDatabase) = db.caseHistoryDao() + + @Provides + fun listDao(db: CrisisCleanupDatabase) = db.listDao() } 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 4ad95665c..8a167465e 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 @@ -28,6 +28,8 @@ data class IncidentEntity( val name: String, @ColumnInfo("short_name", defaultValue = "") val shortName: String, + @ColumnInfo("case_label", defaultValue = "") + val caseLabel: String, @ColumnInfo("incident_type", defaultValue = "") val type: String, // Comma delimited phone numbers if defined diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt new file mode 100644 index 000000000..ffb85724c --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt @@ -0,0 +1,68 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.Index +import androidx.room.Index.Order +import androidx.room.PrimaryKey +import kotlinx.datetime.Instant + +@Entity( + "lists", + indices = [ + // Locally created unsynced lists have a network_id=-1. + // The local/global UUID keeps these rows unique within the table. + Index(value = ["network_id", "local_global_uuid"], unique = true), + Index( + value = ["incident_id", "updated_at"], + orders = [Order.DESC, Order.DESC], + ), + Index( + value = ["updated_at"], + orders = [Order.DESC], + ), + Index( + value = ["model", "updated_at"], + orders = [Order.DESC, Order.DESC], + ), + Index(value = ["parent", "list_order"]), + ], + foreignKeys = [ + ForeignKey( + entity = IncidentEntity::class, + parentColumns = ["id"], + childColumns = ["incident_id"], + onDelete = ForeignKey.NO_ACTION, + ), + ], +) +data class ListEntity( + @PrimaryKey(true) + val id: Long, + @ColumnInfo("network_id", defaultValue = "-1") + val networkId: Long, + @ColumnInfo("local_global_uuid", defaultValue = "") + val localGlobalUuid: String, + @ColumnInfo("created_by") + val createdBy: Long?, + @ColumnInfo("updated_by") + val updatedBy: Long?, + @ColumnInfo("created_at") + val createdAt: Instant, + @ColumnInfo("updated_at") + val updatedAt: Instant, + val parent: Long?, + val name: String, + val description: String?, + @ColumnInfo("list_order") + val listOrder: Long?, + val tags: String?, + val model: String, + @ColumnInfo("object_ids", defaultValue = "") + val objectIds: String, + val shared: String, + val permissions: String, + @ColumnInfo("incident_id") + val incidentId: Long?, +) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncident.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncident.kt index fc68d2b3e..8601780cf 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncident.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedIncident.kt @@ -27,6 +27,7 @@ fun PopulatedIncident.asExternalModel() = with(entity) { id = id, name = name, shortName = shortName, + caseLabel = caseLabel, activePhoneNumbers = activePhoneNumber?.split(",")?.map { it.trim() } ?.filter(String::isNotEmpty) ?: emptyList(), diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedList.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedList.kt new file mode 100644 index 000000000..6ade4ef69 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedList.kt @@ -0,0 +1,49 @@ +package com.crisiscleanup.core.database.model + +import androidx.room.Embedded +import androidx.room.Relation +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.core.model.data.IncidentIdNameType +import com.crisiscleanup.core.model.data.listModelFromLiteral +import com.crisiscleanup.core.model.data.listPermissionFromLiteral +import com.crisiscleanup.core.model.data.listShareFromLiteral + +data class PopulatedList( + @Embedded + val entity: ListEntity, + @Relation( + parentColumn = "incident_id", + entityColumn = "id", + ) + val incident: IncidentEntity?, +) + +fun PopulatedList.asExternalModel() = with(entity) { + val numericObjectIds = objectIds.trim().split(",") + .mapNotNull { it.trim().toLongOrNull() } + CrisisCleanupList( + id = id, + updatedAt = updatedAt, + networkId = networkId, + parentNetworkId = parent, + name = name, + description = description ?: "", + listOrder = listOrder, + tags = tags, + model = listModelFromLiteral(model), + objectIds = numericObjectIds, + shared = listShareFromLiteral(shared), + permission = listPermissionFromLiteral(permissions), + incidentId = incidentId ?: EmptyIncident.id, + incident = incident?.let { + IncidentIdNameType( + id = incident.id, + name = incident.name, + shortName = incident.shortName, + disasterLiteral = incident.type, + ) + } ?: EmptyList.incident, + ) +} diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/PhoneCallDialog.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/PhoneCallDialog.kt new file mode 100644 index 000000000..295aca431 --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/PhoneCallDialog.kt @@ -0,0 +1,48 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import com.crisiscleanup.core.common.ParsedPhoneNumber +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.theme.listItemModifier + +@Composable +fun PhoneCallDialog( + parsedNumbers: List, + onCloseDialog: () -> Unit, +) { + if (parsedNumbers.flatMap(ParsedPhoneNumber::parsedNumbers).isNotEmpty()) { + CrisisCleanupAlertDialog( + title = LocalAppTranslator.current("workType.phone"), + onDismissRequest = onCloseDialog, + confirmButton = { + CrisisCleanupTextButton( + text = LocalAppTranslator.current("actions.close"), + onClick = onCloseDialog, + modifier = Modifier.testTag("phoneNumbersCloseAction"), + ) + }, + ) { + Column { + for (parsedNumber in parsedNumbers) { + if (parsedNumber.parsedNumbers.isNotEmpty()) { + for (phoneNumber in parsedNumber.parsedNumbers) { + LinkifyPhoneText( + text = phoneNumber, + modifier = listItemModifier.testTag("phoneDialogLinkedText"), + ) + } + } else { + Text( + parsedNumber.source, + modifier = listItemModifier.testTag("phoneDialogPhoneSourceText"), + ) + } + } + } + } + } +} diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt index d61830436..44c9ab6bc 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt @@ -270,6 +270,7 @@ fun TopAppBarBackCaretAction( onAction: () -> Unit = {}, colors: TopAppBarColors = TopAppBarDefaults.centerAlignedTopAppBarColors(), actionTestTag: String = "topNavBackAction", + actions: @Composable RowScope.() -> Unit = {}, ) { val titleContent = @Composable { TruncatedAppBarText(modifier, titleResId, title) @@ -285,7 +286,7 @@ fun TopAppBarBackCaretAction( CenterAlignedTopAppBar( title = titleContent, navigationIcon = navigationContent, - actions = { }, + actions = actions, colors = colors, modifier = modifier, ) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/WorksiteViews.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/WorksiteViews.kt new file mode 100644 index 000000000..6ebcd8359 --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/WorksiteViews.kt @@ -0,0 +1,112 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import com.crisiscleanup.core.common.ParsedPhoneNumber +import com.crisiscleanup.core.common.PhoneNumberUtil +import com.crisiscleanup.core.common.openDialer +import com.crisiscleanup.core.common.openMaps +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy +import com.crisiscleanup.core.designsystem.theme.neutralIconColor + +@Composable +fun WorksiteNameView(name: String) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = listItemSpacedBy, + ) { + Icon( + imageVector = CrisisCleanupIcons.Person, + contentDescription = LocalAppTranslator.current("formLabels.name"), + tint = neutralIconColor, + modifier = Modifier.testTag("worksitePersonIcon"), + ) + Text( + name, + modifier = Modifier.testTag("worksiteName"), + ) + } +} + +@Composable +fun WorksiteAddressView( + fullAddress: String, + postView: @Composable () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = listItemSpacedBy, + ) { + Icon( + imageVector = CrisisCleanupIcons.Location, + contentDescription = LocalAppTranslator.current("casesVue.full_address"), + tint = neutralIconColor, + modifier = Modifier.testTag("worksiteLocationIcon"), + ) + Text( + fullAddress, + Modifier + .testTag("worksiteAddress") + .weight(1f), + ) + postView() + } +} + +@Composable +fun WorksiteCallButton( + phone1: String, + phone2: String, + enable: Boolean, + onShowPhoneNumbers: (List) -> Unit, +) { + val context = LocalContext.current + val enableCall = enable && (phone1.isNotBlank() || phone2.isNotBlank()) + CrisisCleanupOutlinedButton( + onClick = { + val parsedNumbers = PhoneNumberUtil.getPhoneNumbers(listOf(phone1, phone2)) + if (parsedNumbers.size == 1 && parsedNumbers.first().parsedNumbers.size == 1) { + context.openDialer(parsedNumbers.first().parsedNumbers.first()) + } else { + onShowPhoneNumbers(parsedNumbers) + } + }, + enabled = enableCall, + ) { + Icon( + imageVector = CrisisCleanupIcons.Phone, + contentDescription = LocalAppTranslator.current("nav.phone"), + modifier = Modifier.testTag("worksiteCallAction"), + ) + } +} + +@Composable +fun WorksiteAddressButton( + geoQuery: String, + locationQuery: String, + isEditable: Boolean, +) { + val context = LocalContext.current + CrisisCleanupOutlinedButton( + onClick = { + val query = geoQuery.ifBlank { locationQuery } + context.openMaps(query) + }, + enabled = isEditable && geoQuery.isNotBlank(), + ) { + Icon( + imageVector = CrisisCleanupIcons.Directions, + contentDescription = LocalAppTranslator.current("caseView.directions"), + modifier = Modifier.testTag("worksiteDirectionsAction"), + ) + } +} diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt index dc4a5cb15..417b9cc54 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBackIos import androidx.compose.material.icons.automirrored.filled.HelpOutline +import androidx.compose.material.icons.automirrored.filled.List import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth @@ -15,16 +16,20 @@ import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud import androidx.compose.material.icons.filled.CloudSync import androidx.compose.material.icons.filled.Delete +import androidx.compose.material.icons.filled.Description import androidx.compose.material.icons.filled.Directions +import androidx.compose.material.icons.filled.Domain import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.ExpandLess import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.Info import androidx.compose.material.icons.filled.KeyboardArrowUp import androidx.compose.material.icons.filled.LocationOn import androidx.compose.material.icons.filled.Mail import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MyLocation +import androidx.compose.material.icons.filled.Nature import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.PersonOutline import androidx.compose.material.icons.filled.Phone @@ -70,13 +75,18 @@ object CrisisCleanupIcons { val ExpandAll = icons.UnfoldMore val ExpandLess = icons.ExpandLess val ExpandMore = icons.ExpandMore + val File = icons.Description val Help = Icons.AutoMirrored.Filled.HelpOutline + val Incident = icons.Nature + val Info = icons.Info + val List = Icons.AutoMirrored.Filled.List val Location = icons.LocationOn val Mail = icons.Mail val Minus = icons.Remove val Menu = icons.Menu val MoreVert = icons.MoreVert val MyLocation = icons.MyLocation + val Organization = icons.Domain val Person = icons.Person val Phone = icons.Phone val PhotoGrid = icons.PhotoLibrary diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/StyleModifier.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/StyleModifier.kt index 2775f0218..55e6f16ed 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/StyleModifier.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/StyleModifier.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp @@ -20,6 +21,10 @@ val listItemModifier = Modifier val listItemHorizontalPadding = PaddingValues(horizontal = 16.dp) val listItemSpacedBy = Arrangement.spacedBy(16.dp) +val listItemCenterSpacedByHalf = Arrangement.spacedBy( + 8.dp, + alignment = Alignment.CenterVertically, +) val listItemSpacedByHalf = Arrangement.spacedBy(8.dp) fun Modifier.listItemHeight() = this.heightIn(min = 56.dp) diff --git a/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadSelectIncidents.kt b/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadSelectIncidents.kt index 623e5fb50..ded49f55d 100644 --- a/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadSelectIncidents.kt +++ b/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadSelectIncidents.kt @@ -64,11 +64,16 @@ class LoadSelectIncidents( coroutineScope.launch { (data.first() as? IncidentsData.Incidents)?.let { incidentsData -> incidentsData.incidents.find { it.id == incident.id }?.let { matchingIncident -> - appPreferencesRepository.setSelectedIncident(matchingIncident.id) - - incidentSelector.setIncident(matchingIncident) + persistIncident(matchingIncident) } } } } + + suspend fun persistIncident(incident: Incident) { + if (incident != EmptyIncident) { + appPreferencesRepository.setSelectedIncident(incident.id) + incidentSelector.setIncident(incident) + } + } } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt new file mode 100644 index 000000000..78ed02855 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt @@ -0,0 +1,78 @@ +package com.crisiscleanup.core.model.data + +import kotlinx.datetime.Instant + +data class CrisisCleanupList( + val id: Long, + val updatedAt: Instant, + val networkId: Long, + val parentNetworkId: Long?, + val name: String, + val description: String, + val listOrder: Long?, + val tags: String?, + val model: ListModel, + val objectIds: List, + val shared: ListShare, + val permission: ListPermission, + val incidentId: Long, + val incident: IncidentIdNameType?, +) + +val EmptyList = CrisisCleanupList( + id = 0, + updatedAt = Instant.fromEpochSeconds(0), + networkId = 0, + parentNetworkId = null, + name = "", + description = "", + listOrder = null, + tags = null, + model = ListModel.None, + objectIds = emptyList(), + shared = ListShare.Private, + permission = ListPermission.Read, + incidentId = EmptyIncident.id, + incident = IncidentIdNameType(id = EmptyIncident.id, "", "", ""), +) + +enum class ListModel(val literal: String) { + None(""), + File("file_files"), + Incident("incident_incidents"), + List("list_lists"), + Organization("organization_organizations"), + OrganizationIncidentTeam("organization_organizations_incidents_teams"), + User("user_users"), + Worksite("worksite_worksites"), +} + +private val modelLiteralLookup = ListModel.entries.associateBy(ListModel::literal) + +fun listModelFromLiteral(literal: String) = modelLiteralLookup[literal] ?: ListModel.None + +enum class ListPermission(val literal: String) { + Read("read_only"), + ReadCopy("read_copy"), + ReadWriteCopy("read_write_copy"), + ReadWriteDeleteCopy("read_write_delete_copy"), +} + +private val permissionLiteralLookup = ListPermission.entries.associateBy(ListPermission::literal) + +fun listPermissionFromLiteral(literal: String) = + permissionLiteralLookup[literal] ?: ListPermission.Read + +enum class ListShare(val literal: String) { + All("all"), + GroupAffiliates("groups_affiliates"), + Organization("organization"), + Private("private"), + Public("public"), + Team("team"), +} + +private val shareLiteralLookup = ListShare.entries.associateBy(ListShare::literal) + +fun listShareFromLiteral(literal: String) = + shareLiteralLookup[literal] ?: ListShare.Private diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt index d63f4089b..fa4cd5250 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt @@ -4,12 +4,14 @@ data class Incident( val id: Long, val name: String, val shortName: String, + val caseLabel: String, val locations: List, val activePhoneNumbers: List, val formFields: List, val turnOnRelease: Boolean, val disasterLiteral: String = "", val disaster: Disaster = disasterFromLiteral(disasterLiteral), + val displayLabel: String = if (caseLabel.isBlank()) name else "$caseLabel: $name", ) { val formFieldLookup: Map by lazy { formFields.associateBy { it.fieldKey } @@ -29,6 +31,7 @@ val EmptyIncident = Incident( -1, "", "", + "", emptyList(), emptyList(), emptyList(), @@ -73,4 +76,5 @@ data class IncidentIdNameType( val name: String, val shortName: String, val disasterLiteral: String, + val disaster: Disaster = disasterFromLiteral(disasterLiteral), ) 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 e2cfaae31..fca9a1946 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 @@ -140,7 +140,7 @@ enum class WorksiteFlagType(val literal: String) { WrongIncident("flag.worksite_wrong_incident"), } -private val flagLiteralLookup = WorksiteFlagType.values().associateBy(WorksiteFlagType::literal) +private val flagLiteralLookup = WorksiteFlagType.entries.associateBy(WorksiteFlagType::literal) fun flagFromLiteral(literal: String) = flagLiteralLookup[literal] @@ -235,6 +235,6 @@ enum class WorksiteSortBy(val literal: String, val translateKey: String) { CountyParish("county-parish", "worksiteFilters.sort_by_county"), } -private val sortByLookup = WorksiteSortBy.values().associateBy(WorksiteSortBy::literal) +private val sortByLookup = WorksiteSortBy.entries.associateBy(WorksiteSortBy::literal) fun worksiteSortByFromLiteral(literal: String) = sortByLookup[literal] ?: WorksiteSortBy.None 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 3edaaf17b..df1e3ce81 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 @@ -8,6 +8,8 @@ import com.crisiscleanup.core.network.model.NetworkIncident import com.crisiscleanup.core.network.model.NetworkIncidentOrganization import com.crisiscleanup.core.network.model.NetworkLanguageDescription import com.crisiscleanup.core.network.model.NetworkLanguageTranslation +import com.crisiscleanup.core.network.model.NetworkList +import com.crisiscleanup.core.network.model.NetworkListsResult import com.crisiscleanup.core.network.model.NetworkLocation import com.crisiscleanup.core.network.model.NetworkOrganizationShort import com.crisiscleanup.core.network.model.NetworkOrganizationsResult @@ -124,4 +126,13 @@ interface CrisisCleanupNetworkDataSource { suspend fun getProfile(accessToken: String): NetworkUserProfile? suspend fun getRequestRedeployIncidentIds(): Set + + suspend fun getLists( + limit: Int = 100, + offset: Int? = null, + ): NetworkListsResult + + suspend fun getList(id: Long): NetworkList? + + suspend fun getLists(ids: List): List } 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 70858ed7c..2b0fb305c 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 @@ -35,6 +35,8 @@ data class NetworkIncident( val name: String, @SerialName("short_name") val shortName: String, + @SerialName("case_label") + val caseLabel: String, val locations: List, @SerialName("incident_type") val type: String, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkList.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkList.kt new file mode 100644 index 000000000..ef57cf226 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkList.kt @@ -0,0 +1,45 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkListsResult( + val errors: List? = null, + val count: Int? = null, + val results: List? = null, +) + +@Serializable +data class NetworkList( + val id: Long, + @SerialName("created_by") + val createdBy: Long?, + @SerialName("updated_by") + val updatedBy: Long?, + @SerialName("created_at") + val createdAt: Instant, + @SerialName("updated_at") + val updatedAt: Instant, + val parent: Long?, + val name: String, + val description: String?, + @SerialName("list_order") + val listOrder: Long?, + val tags: String?, + val model: String, + @SerialName("object_ids") + val objectIds: List?, + val shared: String, + val permissions: String, + val incident: Long?, + @SerialName("invalidate_at") + val invalidateAt: Instant?, +) + +@Serializable +data class NetworkListResult( + val errors: List? = null, + val list: NetworkList? = null, +) 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 fbcea248b..bb150c21a 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 @@ -10,6 +10,9 @@ import com.crisiscleanup.core.network.model.NetworkIncidentResult import com.crisiscleanup.core.network.model.NetworkIncidentsResult import com.crisiscleanup.core.network.model.NetworkLanguageTranslationResult import com.crisiscleanup.core.network.model.NetworkLanguagesResult +import com.crisiscleanup.core.network.model.NetworkList +import com.crisiscleanup.core.network.model.NetworkListResult +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 @@ -244,6 +247,21 @@ private interface DataSourceApi { @Query("offset") offset: Int?, @Query("updated_at__gt") updatedAtAfter: Instant?, ): NetworkFlagsFormDataResult + + @TokenAuthenticationHeader + @GET("lists") + suspend fun getLists( + @Query("limit") limit: Int, + @Query("offset") offset: Int?, + ): NetworkListsResult + + @TokenAuthenticationHeader + @WrapResponseHeader("list") + @ThrowClientErrorHeader + @GET("lists/{listId}") + suspend fun getList( + @Path("listId") id: Long, + ): NetworkListResult } private val worksiteCoreDataFields = listOf( @@ -470,4 +488,25 @@ class DataApiClient @Inject constructor( override suspend fun getRequestRedeployIncidentIds() = networkApi.getRedeployRequests().results?.map { it.incident }?.toSet() ?: emptySet() + + override suspend fun getLists(limit: Int, offset: Int?) = networkApi.getLists(limit, offset) + + override suspend fun getList(id: Long): NetworkList? { + val result = networkApi.getList(id) + result.errors?.tryThrowException() + return result.list + } + + override suspend fun getLists(ids: List): List { + val networkLists = mutableListOf() + for (id in ids) { + var list: NetworkList? = null + try { + list = getList(id) + } catch (_: Exception) { + } + networkLists.add(list) + } + return networkLists + } } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt index 4001e49ab..2f2883851 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt @@ -64,9 +64,9 @@ private interface RegisterApi { @Path("user") userId: Long, ): NetworkUser - @GET("organizations/{organization}") + @GET("organizations/{organizationId}") suspend fun noAuthOrganization( - @Path("organization") organizationId: Long, + @Path("organizationId") organizationId: Long, ): NetworkOrganizationShort @POST("invitations/accept") diff --git a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkIncidentTest.kt b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkIncidentTest.kt index 9fa83caf4..9b0b79178 100644 --- a/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkIncidentTest.kt +++ b/core/network/src/test/java/com/crisiscleanup/core/network/model/NetworkIncidentTest.kt @@ -12,6 +12,7 @@ class NetworkIncidentTest { "2019-09-25T00:00:00Z", "Small Tornado (Fake)", "chippewa_dunn_wi_tornado", + "A", "tornado", listOf(NetworkIncidentLocation(129, 41905)), ), @@ -20,6 +21,7 @@ class NetworkIncidentTest { "2022-07-20T16:28:51Z", "Another Tornado (Fake)", "another_tornado", + "B", "tornado", listOf( NetworkIncidentLocation(1, 73132), @@ -31,6 +33,7 @@ class NetworkIncidentTest { "2021-03-10T02:33:48Z", "Pandemic (Fake)", "covid_19_response", + "C", "virus", listOf(NetworkIncidentLocation(2, 73141)), ), @@ -39,6 +42,7 @@ class NetworkIncidentTest { "2017-08-24T00:00:00Z", "Big Hurricane (Fake)", "hurricane_harvey", + "d", "hurricane", listOf(NetworkIncidentLocation(63, 41823)), isArchived = true, @@ -48,6 +52,7 @@ class NetworkIncidentTest { "2019-07-22T00:00:00Z", "Medium Storm (Fake)", "n_wi_derecho_jul_2019", + "fg", "wind", listOf(NetworkIncidentLocation(122, 41898)), ), 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 ffa5ce0ed..05814b546 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 @@ -1,7 +1,6 @@ package com.crisiscleanup.core.network.model import kotlinx.datetime.Instant -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json object TestUtil { @@ -19,6 +18,7 @@ internal fun fillNetworkIncident( startAt: String, name: String, shortName: String, + caseLabel: String, incidentType: String, locations: List, activePhone: List? = null, @@ -27,7 +27,9 @@ internal fun fillNetworkIncident( ) = NetworkIncident( id, Instant.parse(startAt), - name, shortName, + name, + shortName, + caseLabel, locations, incidentType, activePhone, 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 d65f80fe4..a66053231 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 @@ -3,7 +3,6 @@ package com.crisiscleanup.core.network.model.util import com.crisiscleanup.core.network.model.NetworkCrisisCleanupApiError import com.crisiscleanup.core.network.model.NetworkIncident import kotlinx.datetime.Instant -import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Test @@ -106,13 +105,14 @@ class IterableStringSerializerTest { } private fun makeExpectedNetworkIncident(expectedPhoneNumber: String?) = - """{"id":0,"start_at":"2023-02-06T16:13:20Z","name":"","short_name":"","locations":[],"incident_type":"","active_phone_number":$expectedPhoneNumber,"turn_on_release":false,"is_archived":null,"form_fields":[]}""" + """{"id":0,"start_at":"2023-02-06T16:13:20Z","name":"","short_name":"","case_label":"","locations":[],"incident_type":"","active_phone_number":$expectedPhoneNumber,"turn_on_release":false,"is_archived":null,"form_fields":[]}""" private fun testNetworkIncident(phoneNumbers: List?) = NetworkIncident( 0, testInstant, "", "", + "", emptyList(), "", phoneNumbers, diff --git a/core/network/src/test/resources/getIncidentsSuccess.json b/core/network/src/test/resources/getIncidentsSuccess.json index ccccda075..6073b100e 100644 --- a/core/network/src/test/resources/getIncidentsSuccess.json +++ b/core/network/src/test/resources/getIncidentsSuccess.json @@ -8,6 +8,7 @@ "name": "Small Tornado (Fake)", "start_at": "2019-09-25T00:00:00Z", "short_name": "chippewa_dunn_wi_tornado", + "case_label": "A", "locations": [ { "tag": null, @@ -29,6 +30,7 @@ "name": "Another Tornado (Fake)", "start_at": "2022-07-20T16:28:51Z", "short_name": "another_tornado", + "case_label": "B", "locations": [ { "tag": null, @@ -59,6 +61,7 @@ "name": "Pandemic (Fake)", "start_at": "2021-03-10T02:33:48Z", "short_name": "covid_19_response", + "case_label": "C", "locations": [ { "tag": null, @@ -80,6 +83,7 @@ "name": "Big Hurricane (Fake)", "start_at": "2017-08-24T00:00:00Z", "short_name": "hurricane_harvey", + "case_label": "d", "locations": [ { "tag": null, @@ -101,6 +105,7 @@ "name": "Medium Storm (Fake)", "start_at": "2019-07-22T00:00:00Z", "short_name": "n_wi_derecho_jul_2019", + "case_label": "fg", "locations": [ { "tag": null, diff --git a/core/selectincident/src/main/java/com/crisiscleanup/core/selectincident/SelectIncidentDialog.kt b/core/selectincident/src/main/java/com/crisiscleanup/core/selectincident/SelectIncidentDialog.kt index 32b7c4696..30e400d02 100644 --- a/core/selectincident/src/main/java/com/crisiscleanup/core/selectincident/SelectIncidentDialog.kt +++ b/core/selectincident/src/main/java/com/crisiscleanup/core/selectincident/SelectIncidentDialog.kt @@ -167,7 +167,7 @@ private fun ColumnScope.IncidentSelectContent( rememberOnSelectIncident(incident) } .padding(padding), - text = incident.name, + text = incident.displayLabel, style = MaterialTheme.typography.bodyLarge, fontWeight = fontWeight, ) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt index cb4520648..cf6086edf 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RequestOrgAccessViewModel.kt @@ -118,10 +118,16 @@ class RequestOrgAccessViewModel @Inject constructor( .onEach { result -> result?.let { if (it.isNewAccountRequest) { - requestSentTitle = translator("requestAccess.request_sent") - requestSentText = translator("requestAccess.request_sent_to_org") - .replace("{organization}", result.organizationName) - .replace("{requested_to}", result.organizationRecipient) + if (showEmailInput) { + requestSentTitle = translator("requestAccess.request_sent") + requestSentText = translator("requestAccess.request_sent_to_org") + .replace("{organization}", result.organizationName) + .replace("{requested_to}", result.organizationRecipient) + } else { + requestSentTitle = translator("info.success") + requestSentText = + translator("invitationSignup.success_accept_invitation") + } } else { inviteInfoErrorMessage.value = translator("requestAccess.already_in_org_error") diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ScanQrCodeViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ScanQrCodeViewModel.kt index df0dd0d7c..9659fa5ac 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ScanQrCodeViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ScanQrCodeViewModel.kt @@ -18,6 +18,7 @@ import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Onboarding import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.common.queryParamMap import com.google.mlkit.vision.barcode.BarcodeScannerOptions import com.google.mlkit.vision.barcode.BarcodeScanning import com.google.mlkit.vision.barcode.common.Barcode @@ -89,15 +90,7 @@ class ScanQrCodeViewModel @Inject constructor( val url = Uri.parse(payload) url.path?.split("/")?.lastOrNull()?.let { lastPath -> if (lastPath == "mobile_app_user_invite") { - val params = mutableMapOf() - for (key in url.queryParameterNames) { - url.getQueryParameter(key)?.let { value -> - params[key] = value - } - } - if (params.isNotEmpty()) { - externalEventBus.onOrgPersistentInvite(params) - } + externalEventBus.onOrgPersistentInvite(url.queryParamMap) } } } catch (e: Exception) { 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 eb954ca76..2778e5db6 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 @@ -421,11 +421,13 @@ class ViewCaseViewModel @Inject constructor( } val workTypeLookup = stateData.incident.workTypeLookup val summaryJobTypes = worksite.formData + ?.asSequence() ?.filter { formValue -> workTypeLookup[formValue.key] == workTypeLiteral } ?.filter { formValue -> formValue.value.isBooleanTrue } ?.map { formValue -> translate("formLabels.${formValue.key}") } ?.filter { jobName -> jobName != name } ?.filter(String::isNotBlank) + ?.toList() ?: emptyList() val summary = listOf( summaryJobTypes.combineTrimText(), diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt index 1cfb3d4ce..6197343c2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/navigation/CaseEditorNavigation.kt @@ -62,6 +62,11 @@ internal class TransferWorkTypeArgs(val isFromCaseEdit: Boolean) { ) } +fun NavController.navigateToViewCase(incidentId: Long, worksiteId: Long) { + val route = "$VIEW_CASE_ROUTE?$INCIDENT_ID_ARG=$incidentId&$WORKSITE_ID_ARG=$worksiteId" + this.navigate(route) +} + fun NavController.navigateToCaseEditor(incidentId: Long, worksiteId: Long? = null) { val routeParts = mutableListOf("$CASE_EDITOR_ROUTE?$INCIDENT_ID_ARG=$incidentId") worksiteId?.let { routeParts.add("$WORKSITE_ID_ARG=$worksiteId") } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseIncidentView.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseIncidentView.kt index bc950ca5d..d845e35c9 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseIncidentView.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseIncidentView.kt @@ -1,28 +1,12 @@ package com.crisiscleanup.feature.caseeditor.ui -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Icon import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.crisiscleanup.core.commonassets.DisasterIcon import com.crisiscleanup.core.commonassets.getDisasterIcon -import com.crisiscleanup.core.designsystem.LocalAppTranslator -import com.crisiscleanup.core.designsystem.component.CrisisCleanupIconButton -import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.commoncase.ui.IncidentHeaderView import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme -import com.crisiscleanup.core.designsystem.theme.LocalFontStyles -import com.crisiscleanup.core.designsystem.theme.listItemHeight -import com.crisiscleanup.core.designsystem.theme.listItemPadding -import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy -import com.crisiscleanup.core.designsystem.theme.primaryOrangeColor import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.Incident @@ -34,45 +18,14 @@ internal fun CaseIncidentView( isSyncing: Boolean = false, scheduleSync: () -> Unit = {}, ) { - val incidentName = incident.shortName - val disasterResId = getDisasterIcon(incident.disaster) - Row( - modifier = modifier - .listItemPadding() - .listItemHeight(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = listItemSpacedBy, - ) { - DisasterIcon(disasterResId, incidentName) - Text( - incidentName, - Modifier.testTag("caseViewIncidentName").weight(1f), - style = LocalFontStyles.current.header1, - ) - - val translator = LocalAppTranslator.current - if (isSyncing) { - Box( - // minimumInteractiveComponentSize > IconButtonTokens.StateLayerSize - modifier = Modifier.size(48.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = CrisisCleanupIcons.CloudSync, - contentDescription = translator("info.is_syncing"), - modifier = Modifier.testTag("caseViewIsSyncingIcon"), - ) - } - } else if (isPendingSync) { - CrisisCleanupIconButton( - onClick = scheduleSync, - imageVector = CrisisCleanupIcons.Cloud, - contentDescription = translator("info.is_pending_sync"), - tint = primaryOrangeColor, - modifier = Modifier.testTag("caseViewIsPendingSyncIconBtn"), - ) - } - } + IncidentHeaderView( + modifier, + incidentName = incident.shortName, + disasterResId = getDisasterIcon(incident.disaster), + isPendingSync = isPendingSync, + isSyncing = isSyncing, + scheduleSync = scheduleSync, + ) } @Preview("syncing") 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 14ec2bf8f..57d2b5d34 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 @@ -60,8 +60,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.appnav.ViewImageArgs import com.crisiscleanup.core.appnav.WorksiteImagesArgs import com.crisiscleanup.core.common.filterNotBlankTrim -import com.crisiscleanup.core.commoncase.com.crisiscleanup.core.commoncase.ui.ExplainWrongLocationDialog import com.crisiscleanup.core.commoncase.model.addressQuery +import com.crisiscleanup.core.commoncase.ui.ExplainWrongLocationDialog import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.LocalLayoutProvider diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesSearchViewModel.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesSearchViewModel.kt index c2fab8cc2..6d7257396 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesSearchViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesSearchViewModel.kt @@ -342,14 +342,26 @@ class CasesSearchViewModel @Inject constructor( isSelectingResult.value = true try { val incidentId = incidentSelector.incidentId.value - val worksiteId = with(result) { + var worksiteId = with(result) { if (summary.id > 0) { summary.id } else { worksitesRepository.getLocalId(networkWorksiteId) } } - selectedWorksite.value = Pair(incidentId, worksiteId) + + if (worksiteId <= 0) { + with(result) { + worksitesRepository.syncNetworkWorksite(networkWorksiteId) + worksiteId = worksitesRepository.getLocalId(networkWorksiteId) + } + } + + if (worksiteId > 0) { + selectedWorksite.value = Pair(incidentId, worksiteId) + } else { + // TODO Inform wait for data to cache + } } catch (e: Exception) { logger.logException(e) } finally { diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt index 54ae8ccff..fb2d7c580 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt @@ -40,7 +40,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight @@ -48,13 +47,10 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.common.ParsedPhoneNumber -import com.crisiscleanup.core.common.PhoneNumberUtil -import com.crisiscleanup.core.common.openDialer -import com.crisiscleanup.core.common.openMaps import com.crisiscleanup.core.commonassets.R -import com.crisiscleanup.core.commoncase.com.crisiscleanup.core.commoncase.ui.ExplainWrongLocationDialog import com.crisiscleanup.core.commoncase.model.addressQuery import com.crisiscleanup.core.commoncase.oneDecimalFormat +import com.crisiscleanup.core.commoncase.ui.ExplainWrongLocationDialog import com.crisiscleanup.core.commoncase.ui.IncidentDropdownSelect import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter @@ -62,9 +58,13 @@ import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.component.FormListSectionSeparator -import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText +import com.crisiscleanup.core.designsystem.component.PhoneCallDialog import com.crisiscleanup.core.designsystem.component.WorkTypeAction import com.crisiscleanup.core.designsystem.component.WorkTypePrimaryAction +import com.crisiscleanup.core.designsystem.component.WorksiteAddressButton +import com.crisiscleanup.core.designsystem.component.WorksiteAddressView +import com.crisiscleanup.core.designsystem.component.WorksiteCallButton +import com.crisiscleanup.core.designsystem.component.WorksiteNameView import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.disabledAlpha @@ -73,7 +73,6 @@ import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf import com.crisiscleanup.core.designsystem.theme.listItemVerticalPadding import com.crisiscleanup.core.designsystem.theme.listRowItemStartPadding -import com.crisiscleanup.core.designsystem.theme.neutralIconColor import com.crisiscleanup.core.designsystem.theme.optionItemHeight import com.crisiscleanup.core.model.data.TableWorksiteClaimAction import com.crisiscleanup.core.model.data.TableWorksiteClaimStatus @@ -243,9 +242,10 @@ internal fun BoxScope.CasesTableView( BusyIndicatorFloatingTopCenter(isTableDataTransient) - PhoneNumbersDialog( - parsedNumbers = phoneNumberList, - setPhoneNumbers = setPhoneNumberList, + val clearPhoneNumbers = remember(viewModel) { { setPhoneNumberList(emptyList()) } } + PhoneCallDialog( + phoneNumberList, + clearPhoneNumbers, ) var isClaimActionDialogVisible by remember(claimActionErrorMessage) { mutableStateOf(true) } @@ -417,38 +417,9 @@ private fun TableViewItem( } } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = listItemSpacedBy, - ) { - Icon( - imageVector = CrisisCleanupIcons.Person, - contentDescription = translator("formLabels.name"), - tint = neutralIconColor, - modifier = Modifier.testTag("tableViewItemPersonIcon"), - ) - Text( - worksite.name, - modifier = Modifier.testTag("tableViewItemWorksiteName"), - ) - } + WorksiteNameView(worksite.name) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = listItemSpacedBy, - ) { - Icon( - imageVector = CrisisCleanupIcons.Location, - contentDescription = translator("casesVue.full_address"), - tint = neutralIconColor, - modifier = Modifier.testTag("tableViewItemLocationIcon"), - ) - Text( - fullAddress, - Modifier - .testTag("tableViewItemWorksiteFullAddress") - .weight(1f), - ) + WorksiteAddressView(fullAddress) { if (worksite.hasWrongLocationFlag) { ExplainWrongLocationDialog(worksite) } @@ -458,39 +429,18 @@ private fun TableViewItem( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = listItemSpacedBy, ) { - val context = LocalContext.current - CrisisCleanupOutlinedButton( - onClick = { - val parsedNumbers = - PhoneNumberUtil.getPhoneNumbers(listOf(worksite.phone1, worksite.phone2)) - if (parsedNumbers.size == 1 && parsedNumbers.first().parsedNumbers.size == 1) { - context.openDialer(parsedNumbers.first().parsedNumbers.first()) - } else { - showPhoneNumbers(parsedNumbers) - } - }, - enabled = isEditable && (worksite.phone1.isNotBlank() || worksite.phone2.isNotBlank()), - ) { - Icon( - imageVector = CrisisCleanupIcons.Phone, - contentDescription = translator("nav.phone"), - modifier = Modifier.testTag("tableViewItemPhoneBtn"), - ) - } + WorksiteCallButton( + phone1 = worksite.phone1, + phone2 = worksite.phone2, + enable = isEditable, + onShowPhoneNumbers = showPhoneNumbers, + ) - CrisisCleanupOutlinedButton( - onClick = { - val query = geoQuery.ifBlank { locationQuery } - context.openMaps(query) - }, - enabled = isEditable && geoQuery.isNotBlank(), - ) { - Icon( - imageVector = CrisisCleanupIcons.Directions, - contentDescription = translator("caseView.directions"), - modifier = Modifier.testTag("tableViewItemDirectionsBtn"), - ) - } + WorksiteAddressButton( + geoQuery = geoQuery, + locationQuery = locationQuery, + isEditable = isEditable, + ) // TODO Implement add to team when team management is in play @@ -546,42 +496,3 @@ private fun TableViewItem( } } } - -@Composable -private fun PhoneNumbersDialog( - parsedNumbers: List, - setPhoneNumbers: (List) -> Unit, -) { - if (parsedNumbers.flatMap(ParsedPhoneNumber::parsedNumbers).isNotEmpty()) { - val dismissDialog = { setPhoneNumbers(emptyList()) } - CrisisCleanupAlertDialog( - title = LocalAppTranslator.current("workType.phone"), - onDismissRequest = dismissDialog, - confirmButton = { - CrisisCleanupTextButton( - text = LocalAppTranslator.current("actions.close"), - onClick = dismissDialog, - modifier = Modifier.testTag("tableViewItemPhoneDialogCloseBtn"), - ) - }, - ) { - Column { - for (parsedNumber in parsedNumbers) { - if (parsedNumber.parsedNumbers.isNotEmpty()) { - for (phoneNumber in parsedNumber.parsedNumbers) { - LinkifyPhoneText( - text = phoneNumber, - modifier = listItemModifier.testTag("tableViewItemPhoneDialogLinkifyPhoneText"), - ) - } - } else { - Text( - parsedNumber.source, - modifier = listItemModifier.testTag("tableViewItemPhoneDialogPhoneSourceText"), - ) - } - } - } - } - } -} diff --git a/feature/lists/.gitignore b/feature/lists/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/lists/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/lists/build.gradle.kts b/feature/lists/build.gradle.kts new file mode 100644 index 000000000..1f545eb8a --- /dev/null +++ b/feature/lists/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + alias(libs.plugins.nowinandroid.android.feature) + alias(libs.plugins.nowinandroid.android.library.compose) + alias(libs.plugins.nowinandroid.android.library.jacoco) +} + +android { + namespace = "com.crisiscleanup.feature.crisiscleanuplists" +} + +dependencies { + implementation(projects.core.data) + implementation(projects.core.commonassets) + implementation(projects.core.commoncase) + + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.paging.runtime.ktx) + + implementation(libs.kotlinx.datetime) +} \ No newline at end of file diff --git a/feature/lists/src/main/AndroidManifest.xml b/feature/lists/src/main/AndroidManifest.xml new file mode 100644 index 000000000..25df38676 --- /dev/null +++ b/feature/lists/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt new file mode 100644 index 000000000..fc4d97202 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -0,0 +1,91 @@ +package com.crisiscleanup.feature.crisiscleanuplists + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO +import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.repository.ListDataRefresher +import com.crisiscleanup.core.data.repository.ListsRepository +import com.crisiscleanup.core.model.data.EmptyIncident +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ListsViewModel @Inject constructor( + incidentSelector: IncidentSelector, + private val listDataRefresher: ListDataRefresher, + private val listsRepository: ListsRepository, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + val currentIncident = incidentSelector.incident + + val incidentLists = incidentSelector.incident + .filter { it != EmptyIncident } + .flatMapLatest { + listsRepository.streamIncidentLists(it.id) + } + .stateIn( + scope = viewModelScope, + initialValue = emptyList(), + started = SharingStarted.WhileSubscribed(3_000), + ) + + val isRefreshingData = MutableStateFlow(false) + + val allLists = listsRepository.pageLists() + .flowOn(ioDispatcher) + .cachedIn(viewModelScope) + + private val isListsRefreshed = MutableStateFlow(false) + + val openAllListsTab = combine( + isListsRefreshed, + incidentSelector.incidentId, + ::Pair, + ) + .filter { (isRefreshed, incidentId) -> + isRefreshed && incidentId != EmptyIncident.id + } + .map { (_, incidentId) -> + if (listsRepository.getIncidentListCount(incidentId) == 0) { + return@map true + } + false + } + .distinctUntilChanged() + .flowOn(ioDispatcher) + + init { + refreshLists() + } + + fun refreshLists(force: Boolean = false) { + if (isRefreshingData.value) { + return + } + isRefreshingData.value = true + + viewModelScope.launch(ioDispatcher) { + try { + listDataRefresher.refreshListData(force) + + isListsRefreshed.value = true + } finally { + isRefreshingData.value = false + } + } + } +} diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt new file mode 100644 index 000000000..939862150 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -0,0 +1,216 @@ +package com.crisiscleanup.feature.crisiscleanuplists + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.core.common.network.CrisisCleanupDispatchers +import com.crisiscleanup.core.common.network.Dispatcher +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.IncidentsRepository +import com.crisiscleanup.core.data.repository.ListsRepository +import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository +import com.crisiscleanup.core.domain.LoadSelectIncidents +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.core.model.data.EmptyWorksite +import com.crisiscleanup.core.model.data.ListModel +import com.crisiscleanup.core.model.data.Worksite +import com.crisiscleanup.feature.crisiscleanuplists.navigation.ViewListArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class ViewListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + listsRepository: ListsRepository, + private val incidentsRepository: IncidentsRepository, + accountDataRepository: AccountDataRepository, + private val incidentSelector: IncidentSelector, + appPreferencesRepository: LocalAppPreferencesRepository, + private val translator: KeyResourceTranslator, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, + @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : ViewModel() { + private val viewListArgs = ViewListArgs(savedStateHandle) + + private val listId = viewListArgs.listId + + private val loadSelectIncidents = LoadSelectIncidents( + incidentsRepository = incidentsRepository, + accountDataRepository = accountDataRepository, + incidentSelector = incidentSelector, + appPreferencesRepository = appPreferencesRepository, + coroutineScope = viewModelScope, + ) + + val viewState = listsRepository.streamList(listId) + .mapLatest { list -> + if (list == EmptyList) { + val listNotFound = + translator("~~List was not found. It is likely deleted.") + return@mapLatest ViewListViewState.Error(listNotFound) + } + + val lookup = listsRepository.getListObjectData(list) + var objectIds = list.objectIds + if (list.model == ListModel.List) { + objectIds = objectIds.filter { it != list.networkId } + } + val objectData = objectIds.map { id -> + lookup[id] + } + ViewListViewState.Success(list, objectData) + } + .flowOn(ioDispatcher) + .stateIn( + scope = viewModelScope, + initialValue = ViewListViewState.Loading, + started = SharingStarted.WhileSubscribed(3_000), + ) + + val screenTitle = viewState.map { + (it as? ViewListViewState.Success)?.list?.let { list -> + return@map list.name + } + + translator("~~List") + } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(3_000), + ) + + var isConfirmingOpenWorksite by mutableStateOf(false) + private set + var openWorksiteId by mutableStateOf(ExistingWorksiteIdentifierNone) + var openWorksiteError by mutableStateOf("") + + var isChangingIncident by mutableStateOf(false) + private set + private var openWorksiteChangeIncident = EmptyIncident + private var pendingOpenWorksite = EmptyWorksite + var changeIncidentConfirmMessage by mutableStateOf("") + private set + + init { + viewModelScope.launch(ioDispatcher) { + listsRepository.refreshList(listId) + } + } + + fun onConfirmChangeIncident() { + if (isChangingIncident) { + return + } + + val changeIncident = openWorksiteChangeIncident + val changeWorksite = pendingOpenWorksite + try { + if (changeIncident == EmptyIncident || + changeWorksite == EmptyWorksite + ) { + return + } + } finally { + clearChangeIncident() + } + + isChangingIncident = true + viewModelScope.launch(ioDispatcher) { + try { + loadSelectIncidents.persistIncident(changeIncident) + openWorksiteId = ExistingWorksiteIdentifier(changeIncident.id, changeWorksite.id) + } finally { + isChangingIncident = false + } + } + } + + fun clearChangeIncident() { + openWorksiteChangeIncident = EmptyIncident + pendingOpenWorksite = EmptyWorksite + changeIncidentConfirmMessage = "" + } + + fun onOpenWorksite(worksite: Worksite) { + if (worksite == EmptyWorksite || + isConfirmingOpenWorksite || + isChangingIncident + ) { + return + } + isConfirmingOpenWorksite = true + + viewModelScope.launch(ioDispatcher) { + try { + (viewState.value as? ViewListViewState.Success)?.list.let { list -> + val targetIncidentId = worksite.incidentId + if (list?.incident?.id == targetIncidentId) { + val targetWorksiteId = ExistingWorksiteIdentifier( + incidentId = targetIncidentId, + worksiteId = worksite.id, + ) + if (targetIncidentId == incidentSelector.incident.value.id) { + openWorksiteId = targetWorksiteId + } else { + val cachedIncident = incidentsRepository.getIncident(targetIncidentId) + if (cachedIncident == null) { + openWorksiteError = + translator("~~This incident needs downloading.") + } else { + openWorksiteChangeIncident = cachedIncident + pendingOpenWorksite = worksite + changeIncidentConfirmMessage = + translator("~~Would you like to change to {incident_name} and open Case {case_number}?") + .replace("{incident_name}", cachedIncident.shortName) + .replace("{case_number}", worksite.caseNumber) + } + } + } else { + openWorksiteError = + translator("~~Case {case_number} does not belong in Incident {incident_name}") + .replace("{case_number}", worksite.caseNumber) + .replace("{incident_name}", list?.incident?.shortName ?: "") + .trim() + } + } + } catch (e: Exception) { + logger.logException(e) + } finally { + isConfirmingOpenWorksite = false + } + } + } +} + +sealed interface ViewListViewState { + data object Loading : ViewListViewState + + data class Success( + val list: CrisisCleanupList, + val objectData: List, + ) : ViewListViewState + + data class Error( + val message: String, + ) : ViewListViewState +} diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/model/CrisisCleanupList.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/model/CrisisCleanupList.kt new file mode 100644 index 000000000..201f0a521 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/model/CrisisCleanupList.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup.feature.crisiscleanuplists.model + +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.icon.Icon +import com.crisiscleanup.core.designsystem.icon.Icon.DrawableResourceIcon +import com.crisiscleanup.core.designsystem.icon.Icon.ImageVectorIcon +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.ListModel + +private val modelIconLookup = mapOf( + ListModel.None to ImageVectorIcon(CrisisCleanupIcons.Warning), + ListModel.File to ImageVectorIcon(CrisisCleanupIcons.File), + ListModel.Incident to ImageVectorIcon(CrisisCleanupIcons.Incident), + ListModel.List to ImageVectorIcon(CrisisCleanupIcons.List), + ListModel.Organization to ImageVectorIcon(CrisisCleanupIcons.Organization), + ListModel.OrganizationIncidentTeam to DrawableResourceIcon(CrisisCleanupIcons.Team), + ListModel.User to ImageVectorIcon(CrisisCleanupIcons.Person), + ListModel.Worksite to DrawableResourceIcon(CrisisCleanupIcons.Cases), +) + +val CrisisCleanupList.ListIcon: Icon + get() = modelIconLookup[model]!! diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/navigation/ListsNavigation.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/navigation/ListsNavigation.kt new file mode 100644 index 000000000..43657d9d1 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/navigation/ListsNavigation.kt @@ -0,0 +1,74 @@ +package com.crisiscleanup.feature.crisiscleanuplists.navigation + +import androidx.compose.runtime.remember +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController +import androidx.navigation.NavOptions +import androidx.navigation.NavType +import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.crisiscleanup.core.appnav.RouteConstant.LISTS_ROUTE +import com.crisiscleanup.core.appnav.RouteConstant.VIEW_LIST_ROUTE +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.feature.crisiscleanuplists.ui.ListsRoute +import com.crisiscleanup.feature.crisiscleanuplists.ui.ViewListRoute + +internal const val LIST_ID_ARG = "list_id" + +internal class ViewListArgs(val listId: Long) { + constructor(savedStateHandle: SavedStateHandle) : this( + checkNotNull(savedStateHandle.get(LIST_ID_ARG)), + ) +} + +fun NavController.navigateToLists(navOptions: NavOptions? = null) { + this.navigate(LISTS_ROUTE, navOptions) +} + +fun NavController.navigateToViewList(listId: Long) { + this.navigate("${VIEW_LIST_ROUTE}?$LIST_ID_ARG=$listId") +} + +fun NavGraphBuilder.listsScreen( + navController: NavHostController, + onBack: () -> Unit, +) { + composable(route = LISTS_ROUTE) { + val onOpenList = remember(navController) { + { list: CrisisCleanupList -> + navController.navigateToViewList(list.id) + } + } + + ListsRoute( + onBack = onBack, + onOpenList = onOpenList, + ) + } +} + +fun NavGraphBuilder.viewListScreen( + onBack: () -> Unit, + openList: (Long) -> Unit, + openWorksite: (ExistingWorksiteIdentifier) -> Unit, +) { + composable( + route = "$VIEW_LIST_ROUTE?$LIST_ID_ARG={$LIST_ID_ARG}", + arguments = listOf( + navArgument(LIST_ID_ARG) { + type = NavType.LongType + defaultValue = EmptyList.id + }, + ), + ) { + ViewListRoute( + onBack = onBack, + onOpenList = openList, + onOpenWorksite = openWorksite, + ) + } +} diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt new file mode 100644 index 000000000..5629c0492 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt @@ -0,0 +1,418 @@ +package com.crisiscleanup.feature.crisiscleanuplists.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRow +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.TabRowDefaults.tabIndicatorOffset +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshContainer +import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.crisiscleanup.core.common.relativeTime +import com.crisiscleanup.core.commonassets.getDisasterIcon +import com.crisiscleanup.core.commoncase.ui.IncidentHeaderView +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter +import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog +import com.crisiscleanup.core.designsystem.component.CrisisCleanupIconButton +import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton +import com.crisiscleanup.core.designsystem.component.TopAppBarBackCaretAction +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.icon.Icon +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemCenterSpacedByHalf +import com.crisiscleanup.core.designsystem.theme.listItemHeight +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemPadding +import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf +import com.crisiscleanup.core.designsystem.theme.primaryOrangeColor +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.ListModel +import com.crisiscleanup.feature.crisiscleanuplists.ListsViewModel +import com.crisiscleanup.feature.crisiscleanuplists.model.ListIcon +import kotlinx.coroutines.launch +import kotlin.math.min + +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class, +) +@Composable +internal fun ListsRoute( + onBack: () -> Unit = {}, + onOpenList: (CrisisCleanupList) -> Unit = {}, + viewModel: ListsViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + + val incidentLists by viewModel.incidentLists.collectAsStateWithLifecycle() + val allLists = viewModel.allLists.collectAsLazyPagingItems() + + val tabTitles = remember(incidentLists, allLists.itemCount) { + val incidentText = t("~~Incident") + val allText = t("~~All") + val listCount = allLists.itemCount + listOf( + if (incidentLists.isEmpty()) incidentText else "$incidentText (${incidentLists.size})", + if (listCount == 0) allText else "$allText ($listCount)", + ) + } + + val isLoading by viewModel.isRefreshingData.collectAsStateWithLifecycle() + + val currentIncident by viewModel.currentIncident.collectAsStateWithLifecycle() + + var showReadOnlyDescription by remember { mutableStateOf(false) } + + Column { + TopAppBarBackCaretAction( + title = t("~~Lists"), + onAction = onBack, + actions = { + CrisisCleanupIconButton( + imageVector = CrisisCleanupIcons.Info, + onClick = { + showReadOnlyDescription = true + }, + enabled = true, + ) + }, + ) + + val pullRefreshState = rememberPullToRefreshState() + if (pullRefreshState.isRefreshing) { + LaunchedEffect(true) { + viewModel.refreshLists(true) + pullRefreshState.endRefresh() + } + } + + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f, + ) { tabTitles.size } + + val openAllListsTab by viewModel.openAllListsTab.collectAsStateWithLifecycle(false) + LaunchedEffect(openAllListsTab) { + if (openAllListsTab) { + pagerState.scrollToPage(1) + } + } + + val selectedTabIndex = pagerState.currentPage + val coroutine = rememberCoroutineScope() + TabRow( + selectedTabIndex = selectedTabIndex, + indicator = @Composable { tabPositions -> + TabRowDefaults.SecondaryIndicator( + Modifier.tabIndicatorOffset(tabPositions[selectedTabIndex]), + // TODO Common dimensions + height = 2.dp, + color = primaryOrangeColor, + ) + }, + ) { + tabTitles.forEachIndexed { index, title -> + Tab( + text = { + Text( + title, + style = LocalFontStyles.current.header4, + ) + }, + selected = selectedTabIndex == index, + onClick = { + coroutine.launch { + pagerState.animateScrollToPage(index) + } + }, + modifier = Modifier.testTag("listTab_$index"), + ) + } + } + + var explainSupportList by remember { mutableStateOf(EmptyList) } + val filterOnOpenList = remember(onOpenList) { + { list: CrisisCleanupList -> + when (list.model) { + ListModel.None, + ListModel.File, + ListModel.OrganizationIncidentTeam, + -> + explainSupportList = list + + else -> + onOpenList(list) + } + } + } + + Box( + Modifier + .weight(1f) + .nestedScroll(pullRefreshState.nestedScrollConnection), + ) { + HorizontalPager(state = pagerState) { pagerIndex -> + when (pagerIndex) { + 0 -> IncidentListsView( + incidentLists, + currentIncident, + filterOnOpenList, + ) + + 1 -> AllListsView( + allLists, + filterOnOpenList, + ) + } + } + + BusyIndicatorFloatingTopCenter(isLoading) + + val pullProgress = min(pullRefreshState.progress * 1.5f, 1.0f) + PullToRefreshContainer( + modifier = Modifier + .align(Alignment.TopCenter) + .alpha(pullProgress), + state = pullRefreshState, + ) + } + + if (explainSupportList != EmptyList) { + val dismissExplanation = { explainSupportList = EmptyList } + // TODO Different title and message for list type none + CrisisCleanupAlertDialog( + title = t("~~Unsupported list"), + text = t("~~{list_name} list is not yet supported on this app.") + .replace("{list_name}", explainSupportList.name), + onDismissRequest = dismissExplanation, + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.ok"), + onClick = dismissExplanation, + ) + }, + ) + } + } + + if (showReadOnlyDescription) { + val readOnlyTitle = t("~~Lists are read-only") + val readOnlyDescription = + t("~~Lists (in this app) are currently read-only. Manage lists using Crisis Cleanup in a web browser.") + CrisisCleanupAlertDialog( + onDismissRequest = { showReadOnlyDescription = false }, + title = readOnlyTitle, + text = readOnlyDescription, + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.ok"), + ) { + showReadOnlyDescription = false + } + }, + ) + } +} + +@Composable +private fun IncidentListsView( + incidentLists: List, + incident: Incident, + onOpenList: (CrisisCleanupList) -> Unit = {}, +) { + val t = LocalAppTranslator.current + + val listState = rememberLazyListState() + LazyColumn( + Modifier.fillMaxSize(), + state = listState, + verticalArrangement = listItemSpacedByHalf, + ) { + if (incident != EmptyIncident) { + item(key = "incident-info") { + IncidentHeaderView( + Modifier, + incident.shortName, + getDisasterIcon(incident.disaster), + isSyncing = false, + ) + + LaunchedEffect(Unit) { + listState.scrollToItem(0) + } + } + } + + if (incidentLists.isEmpty()) { + item(key = "static-text") { + Text( + t("~~No lists have been created for this Incident."), + listItemModifier, + ) + } + } else { + items( + incidentLists.size, + key = { incidentLists[it].id }, + contentType = { "list-item" }, + ) { index -> + val list = incidentLists[index] + ListItemSummaryView( + list, + Modifier + .clickable { + onOpenList(list) + } + .then(listItemModifier), + ) + } + } + } +} + +@Composable +private fun AllListsView( + pagingLists: LazyPagingItems, + onOpenList: (CrisisCleanupList) -> Unit = {}, +) { + val listState = rememberLazyListState() + LazyColumn( + Modifier.fillMaxSize(), + state = listState, + verticalArrangement = listItemSpacedByHalf, + ) { + items( + pagingLists.itemCount, + key = pagingLists.itemKey { it.id }, + contentType = { "list-item" }, + ) { index -> + val list = pagingLists[index] + if (list == null) { + Text( + "$index", + Modifier.listItemPadding(), + ) + } else { + ListItemSummaryView( + list, + Modifier + .clickable { + onOpenList(list) + } + .then(listItemModifier), + true, + ) + } + } + + if (pagingLists.loadState.append is LoadState.Loading) { + item( + contentType = { "loading" }, + ) { + // TODO Loading indicator + } + } + } +} + +@Composable +internal fun ListIcon( + list: CrisisCleanupList, +) { + val icon = list.ListIcon + val contentDescription = list.model.literal + when (icon) { + is Icon.ImageVectorIcon -> Icon( + imageVector = icon.imageVector, + contentDescription = contentDescription, + ) + + is Icon.DrawableResourceIcon -> { + Icon( + painter = painterResource(icon.id), + contentDescription = contentDescription, + ) + } + } +} + +@Composable +internal fun ListItemSummaryView( + list: CrisisCleanupList, + modifier: Modifier = Modifier, + showIncident: Boolean = false, +) { + Column( + modifier.listItemHeight(), + verticalArrangement = listItemCenterSpacedByHalf, + ) { + Row(horizontalArrangement = listItemSpacedByHalf) { + ListIcon(list) + + Text( + "${list.name} (${list.objectIds.size})", + style = LocalFontStyles.current.header3, + ) + Spacer(Modifier.weight(1f)) + Text(list.updatedAt.relativeTime) + } + + val incidentName = if (showIncident) list.incident?.shortName ?: "" else "" + val description = list.description.trim() + if (incidentName.isNotBlank() || description.isNotBlank()) { + Row { + if (description.isNotBlank()) { + Text( + description, + Modifier.weight(1f), + ) + } + + if (showIncident) { + if (description.isBlank()) { + Spacer(Modifier.weight(1f)) + } + if (incidentName.isNotBlank()) { + Text(incidentName) + } + } + } + } + } +} diff --git a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt new file mode 100644 index 000000000..2d888e0bd --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt @@ -0,0 +1,440 @@ +package com.crisiscleanup.feature.crisiscleanuplists.ui + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.common.ParsedPhoneNumber +import com.crisiscleanup.core.common.relativeTime +import com.crisiscleanup.core.commonassets.getDisasterIcon +import com.crisiscleanup.core.commoncase.model.addressQuery +import com.crisiscleanup.core.commoncase.ui.ExplainWrongLocationDialog +import com.crisiscleanup.core.commoncase.ui.IncidentHeaderView +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter +import com.crisiscleanup.core.designsystem.component.CrisisCleanupAlertDialog +import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton +import com.crisiscleanup.core.designsystem.component.LinkifyEmailText +import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText +import com.crisiscleanup.core.designsystem.component.PhoneCallDialog +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.component.WorksiteAddressButton +import com.crisiscleanup.core.designsystem.component.WorksiteAddressView +import com.crisiscleanup.core.designsystem.component.WorksiteCallButton +import com.crisiscleanup.core.designsystem.component.WorksiteNameView +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemCenterSpacedByHalf +import com.crisiscleanup.core.designsystem.theme.listItemHeight +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy +import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.EmptyWorksite +import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentOrganization +import com.crisiscleanup.core.model.data.ListModel +import com.crisiscleanup.core.model.data.PersonContact +import com.crisiscleanup.core.model.data.Worksite +import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewModel +import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewState + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ViewListRoute( + onBack: () -> Unit = {}, + onOpenList: (Long) -> Unit = {}, + onOpenWorksite: (ExistingWorksiteIdentifier) -> Unit, + viewModel: ViewListViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + + val screenTitle by viewModel.screenTitle.collectAsStateWithLifecycle() + val viewState by viewModel.viewState.collectAsStateWithLifecycle() + + val isConfirmingOpenWorksite = viewModel.isConfirmingOpenWorksite + val openWorksiteId = viewModel.openWorksiteId + if (openWorksiteId != ExistingWorksiteIdentifierNone) { + onOpenWorksite(openWorksiteId) + viewModel.openWorksiteId = ExistingWorksiteIdentifierNone + } + + val isChangingIncident = viewModel.isChangingIncident + + val indicateLoading = viewState is ViewListViewState.Loading || + isConfirmingOpenWorksite || + isChangingIncident + + Box(Modifier.fillMaxSize()) { + Column { + TopAppBarBackAction( + title = screenTitle, + onAction = onBack, + ) + + when (viewState) { + is ViewListViewState.Success -> { + val successState = (viewState as ViewListViewState.Success) + val list = successState.list + val objectData = successState.objectData + ListDetailsView( + list, + objectData, + onOpenList, + viewModel::onOpenWorksite, + rememberKey = viewModel, + ) + } + + is ViewListViewState.Error -> { + Text((viewState as ViewListViewState.Error).message) + } + + else -> {} + } + } + + BusyIndicatorFloatingTopCenter(indicateLoading) + + val openWorksiteError = viewModel.openWorksiteError + if (openWorksiteError.isNotBlank()) { + val closeDialog = remember(viewModel) { { viewModel.openWorksiteError = "" } } + CrisisCleanupAlertDialog( + title = t("info.error"), + text = openWorksiteError, + onDismissRequest = closeDialog, + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.close"), + onClick = closeDialog, + ) + }, + ) + } + + val changeIncidentConfirmMessage = viewModel.changeIncidentConfirmMessage + if (changeIncidentConfirmMessage.isNotBlank()) { + val closeDialog = viewModel::clearChangeIncident + CrisisCleanupAlertDialog( + title = t("~~Confirm change Incident"), + text = changeIncidentConfirmMessage, + onDismissRequest = closeDialog, + confirmButton = { + CrisisCleanupTextButton( + text = t("actions.continue"), + onClick = viewModel::onConfirmChangeIncident, + ) + }, + dismissButton = { + CrisisCleanupTextButton( + text = t("actions.cancel"), + onClick = closeDialog, + ) + }, + ) + } + } +} + +@Composable +private fun ListDetailsView( + list: CrisisCleanupList, + objectData: List, + onOpenList: (Long) -> Unit, + onOpenWorksite: (Worksite) -> Unit, + rememberKey: Any, +) { + val t = LocalAppTranslator.current + + if (objectData.isEmpty()) { + Text( + t("~~This list is not supported by the app or has no items."), + listItemModifier, + ) + } else { + var phoneNumberList by remember { mutableStateOf(emptyList()) } + val setPhoneNumberList = remember(rememberKey) { + { list: List -> + phoneNumberList = list + } + } + val clearPhoneNumbers = remember(rememberKey) { { setPhoneNumberList(emptyList()) } } + PhoneCallDialog( + parsedNumbers = phoneNumberList, + onCloseDialog = clearPhoneNumbers, + ) + + val openList = remember(rememberKey) { + { list: CrisisCleanupList -> + onOpenList(list.id) + } + } + + LazyColumn(verticalArrangement = listItemCenterSpacedByHalf) { + item { + list.incident?.let { incident -> + IncidentHeaderView( + Modifier, + incident.shortName, + getDisasterIcon(incident.disaster), + isSyncing = false, + ) + } + + Row( + listItemModifier, + horizontalArrangement = listItemSpacedByHalf, + ) { + ListIcon(list) + Text(list.updatedAt.relativeTime) + } + + val description = list.description.trim() + if (description.isNotBlank()) { + Text( + description, + listItemModifier, + ) + } + } + + when (list.model) { + ListModel.Incident -> { + incidentItems(objectData) + } + + ListModel.List -> { + listItems( + objectData, + openList, + ) + } + + ListModel.Organization -> { + organizationItems(objectData) + } + + ListModel.User -> { + userItems(objectData) + } + + ListModel.Worksite -> { + worksiteItems( + list.incident?.id ?: EmptyIncident.id, + objectData, + setPhoneNumberList, + onOpenWorksite, + ) + } + + else -> { + item { + Text(t("~~This list is not supported by the app.")) + } + } + } + } + } +} + +@Composable +private fun MissingItem() { + Box( + listItemModifier.listItemHeight(), + contentAlignment = Alignment.CenterStart, + ) { + Text( + LocalAppTranslator.current("~~Missing list data."), + ) + } +} + +private fun LazyListScope.incidentItems( + listData: List, +) { + // TODO Test when issues are fixed + val incidents = listData.map { it as? Incident } + items( + incidents.size, + key = { incidents[it]?.id ?: -it }, + contentType = { incidents[it]?.id ?: "missing-item" }, + ) { + val incident = incidents[it] + if (incident == null) { + MissingItem() + } else { + IncidentHeaderView( + Modifier, + incident.shortName, + getDisasterIcon(incident.disaster), + isSyncing = false, + ) + } + } +} + +private fun LazyListScope.listItems( + listData: List, + onOpenList: (CrisisCleanupList) -> Unit = {}, +) { + val lists = listData.map { it as? CrisisCleanupList } + items( + lists.size, + key = { lists[it]?.id ?: -it }, + contentType = { lists[it]?.id ?: "missing-item" }, + ) { + val list = lists[it] + if (list == null) { + MissingItem() + } else { + ListItemSummaryView( + list, + Modifier + .clickable { + onOpenList(list) + } + .then(listItemModifier), + true, + ) + } + } +} + +private fun LazyListScope.organizationItems( + listData: List, +) { + val organizations = listData.map { it as? IncidentOrganization } + items( + organizations.size, + key = { organizations[it]?.id ?: -it }, + contentType = { organizations[it]?.id ?: "missing-item" }, + ) { + val organization = organizations[it] + if (organization == null) { + MissingItem() + } else { + Text( + organization.name, + listItemModifier + .listItemHeight() + .wrapContentHeight(align = Alignment.CenterVertically), + ) + } + } +} + +private fun LazyListScope.userItems( + listData: List, +) { + // TODO Test when issues are fixed + val users = listData.map { it as? PersonContact } + items( + users.size, + key = { users[it]?.id ?: -it }, + contentType = { users[it]?.id ?: "missing-item" }, + ) { + val contact = users[it] + if (contact == null) { + MissingItem() + } else { + Column( + listItemModifier.listItemHeight(), + verticalArrangement = listItemCenterSpacedByHalf, + ) { + Text(contact.fullName) + if (contact.mobile.isNotBlank()) { + LinkifyPhoneText(contact.mobile) + } + if (contact.email.isNotBlank()) { + LinkifyEmailText(contact.email) + } + } + } + } +} + +private fun LazyListScope.worksiteItems( + incidentId: Long, + listData: List, + showPhoneNumbers: (List) -> Unit, + onOpenWorksite: (Worksite) -> Unit = {}, +) { + val worksites = listData.map { it as? Worksite } + items( + worksites.size, + key = { worksites[it]?.id ?: -it }, + contentType = { worksites[it]?.id ?: "missing-item" }, + ) { + val worksite = worksites[it] + if (worksite == null || worksite == EmptyWorksite) { + MissingItem() + } else if (worksite.incidentId != incidentId) { + Box( + listItemModifier.listItemHeight(), + contentAlignment = Alignment.CenterStart, + ) { + Text( + LocalAppTranslator.current("~~Case {case_number} is not under this Incident.") + .replace("{case_number}", worksite.caseNumber), + ) + } + } else { + val (fullAddress, geoQuery, locationQuery) = worksite.addressQuery + + Column( + Modifier + .clickable(onClick = { onOpenWorksite(worksite) }) + .then(listItemModifier.listItemHeight()), + verticalArrangement = listItemCenterSpacedByHalf, + ) { + Text( + worksite.caseNumber, + style = LocalFontStyles.current.header3, + ) + + WorksiteNameView(worksite.name) + + WorksiteAddressView(fullAddress) { + if (worksite.hasWrongLocationFlag) { + ExplainWrongLocationDialog(worksite) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = listItemSpacedBy, + ) { + WorksiteCallButton( + phone1 = worksite.phone1, + phone2 = worksite.phone2, + enable = true, + onShowPhoneNumbers = showPhoneNumbers, + ) + + WorksiteAddressButton( + geoQuery = geoQuery, + locationQuery = locationQuery, + isEditable = true, + ) + } + } + } + } +} diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt index ad278a8c0..4622a2725 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt @@ -66,6 +66,7 @@ internal fun MenuRoute( openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, openUserFeedback: () -> Unit = {}, + openLists: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { MenuScreen( @@ -73,6 +74,7 @@ internal fun MenuRoute( openInviteTeammate = openInviteTeammate, openRequestRedeploy = openRequestRedeploy, openUserFeedback = openUserFeedback, + openLists = openLists, openSyncLogs = openSyncLogs, ) } @@ -84,6 +86,7 @@ internal fun MenuScreen( openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, openUserFeedback: () -> Unit = {}, + openLists: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { val t = LocalAppTranslator.current @@ -139,6 +142,13 @@ internal fun MenuScreen( toggleGettingStartedSection = viewModel::showGettingStartedVideo, ) + CrisisCleanupOutlinedButton( + modifier = listItemModifier.actionHeight(), + text = t("~~Lists"), + onClick = openLists, + enabled = true, + ) + CrisisCleanupButton( modifier = listItemModifier, text = t("usersVue.invite_new_user"), diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt index 468a803a3..9d6ee66a9 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt @@ -16,6 +16,7 @@ fun NavGraphBuilder.menuScreen( openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, openUserFeedback: () -> Unit = {}, + openLists: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { composable(route = MENU_ROUTE) { @@ -24,6 +25,7 @@ fun NavGraphBuilder.menuScreen( openInviteTeammate = openInviteTeammate, openRequestRedeploy = openRequestRedeploy, openUserFeedback = openUserFeedback, + openLists = openLists, openSyncLogs = openSyncLogs, ) } diff --git a/feature/syncinsights/build.gradle.kts b/feature/syncinsights/build.gradle.kts index ccd99b787..30016aee8 100644 --- a/feature/syncinsights/build.gradle.kts +++ b/feature/syncinsights/build.gradle.kts @@ -11,5 +11,8 @@ android { dependencies { implementation(projects.core.data) + implementation(libs.androidx.paging.compose) + implementation(libs.androidx.paging.runtime.ktx) + implementation(libs.kotlinx.datetime) } \ No newline at end of file diff --git a/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt b/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt index 9ca7b7126..e92bd7a54 100644 --- a/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt +++ b/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt @@ -3,14 +3,16 @@ package com.crisiscleanup.feature.syncinsights import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn import com.crisiscleanup.core.common.di.ApplicationScope 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.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher -import com.crisiscleanup.core.common.relativeTime import com.crisiscleanup.core.common.sync.SyncPusher +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.data.repository.SyncLogRepository import com.crisiscleanup.core.data.repository.WorksiteChangeRepository import com.crisiscleanup.core.data.repository.WorksitesRepository @@ -18,20 +20,17 @@ import com.crisiscleanup.core.model.data.SyncLog import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.mapLatest -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class SyncInsightsViewModel @Inject constructor( - private val syncLogRepository: SyncLogRepository, + syncLogRepository: SyncLogRepository, private val worksitesRepository: WorksitesRepository, worksiteChangeRepository: WorksiteChangeRepository, private val syncPusher: SyncPusher, @@ -52,42 +51,11 @@ class SyncInsightsViewModel @Inject constructor( it.isNotEmpty() } - private val logSliceCount = 80 - private val logSliceCountHalf = (logSliceCount * 0.5f).toInt() - val listBlockSize = 20 + val openWorksiteId = mutableStateOf(ExistingWorksiteIdentifierNone) - private val queryLogState = MutableStateFlow(Pair(0, 0)) - - val openWorksiteId = mutableStateOf(Pair(0L, 0L)) - - val syncLogs = queryLogState - .mapLatest { (startIndex, totalCount) -> - val logs = syncLogRepository.getLogs( - logSliceCount, - (startIndex - logSliceCountHalf).coerceAtLeast(0), - ) - - val logItems = logs.mapIndexed { index, log -> - val isContinuingLogType = index > 0 && logs[index - 1].logType == log.logType - SyncLogItem(log, isContinuingLogType, log.logTime.relativeTime) - } - LogListQueryState(startIndex, totalCount, logItems) - } + val syncLogs = syncLogRepository.pageLogs() .flowOn(ioDispatcher) - .stateIn( - scope = viewModelScope, - initialValue = LogListQueryState(0, 0, emptyList()), - started = SharingStarted.WhileSubscribed(), - ) - - init { - syncLogRepository.streamLogCount() - .onEach { totalCount -> - queryLogState.value = Pair(syncLogs.value.startIndex, totalCount) - } - .flowOn(ioDispatcher) - .launchIn(viewModelScope) - } + .cachedIn(viewModelScope) fun syncPending() { if (worksitesPendingSync.value.isNotEmpty()) { @@ -97,16 +65,6 @@ class SyncInsightsViewModel @Inject constructor( } } - fun onListBlockPosition(blockPosition: Int) { - with(syncLogs.value) { - var position = blockPosition * listBlockSize - position = (position - worksitesPendingSync.value.size - 2).coerceAtLeast(0) - if (isBoundaryPosition(position)) { - queryLogState.value = Pair(position, count) - } - } - } - private val worksiteLogIdCapture = Regex("worksite-(\\w+-)?(\\d+)-") fun onExpandLog(log: SyncLog) { @@ -122,7 +80,10 @@ class SyncInsightsViewModel @Inject constructor( logger.logDebug("Worksite $localWorksite") logger.logDebug("Unsynced $unsyncedCounts") openWorksiteId.value = - Pair(localWorksite!!.worksite.incidentId, worksiteId) + ExistingWorksiteIdentifier( + localWorksite!!.worksite.incidentId, + worksiteId, + ) } } } catch (e: Exception) { @@ -132,32 +93,3 @@ class SyncInsightsViewModel @Inject constructor( } } } - -data class LogListQueryState( - val startIndex: Int, - val count: Int, - private val data: List, - private val endIndex: Int = startIndex + data.size, -) { - private val boundaryPositions = Pair( - (startIndex + data.size * 0.3f).toInt(), - (startIndex + data.size * 0.7f).toInt(), - ) - - private val hasInnerBoundary = boundaryPositions.first < boundaryPositions.second - - fun isBoundaryPosition(position: Int) = hasInnerBoundary && - (position < boundaryPositions.first || position > boundaryPositions.second) - - fun getLog(index: Int) = if (index in startIndex until endIndex) { - data[index - startIndex] - } else { - null - } -} - -data class SyncLogItem( - val syncLog: SyncLog, - val isContinuingLogType: Boolean, - val relativeTime: String, -) diff --git a/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/ui/SyncInsightsScreen.kt b/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/ui/SyncInsightsScreen.kt index 6ed4ff0ae..24815adc5 100644 --- a/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/ui/SyncInsightsScreen.kt +++ b/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/ui/SyncInsightsScreen.kt @@ -11,22 +11,27 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.crisiscleanup.core.common.relativeTime +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.listItemBottomPadding import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemPadding import com.crisiscleanup.core.designsystem.theme.listItemTopPadding +import com.crisiscleanup.core.model.data.EmptyWorksite +import com.crisiscleanup.core.model.data.SyncLog import com.crisiscleanup.feature.syncinsights.SyncInsightsViewModel -import com.crisiscleanup.feature.syncinsights.SyncLogItem @Composable internal fun SyncInsightsRoute( @@ -54,20 +59,13 @@ internal fun SyncInsightsRoute( } } - val logs by viewModel.syncLogs.collectAsStateWithLifecycle() + val pagingLogs = viewModel.syncLogs.collectAsLazyPagingItems() val listState = rememberLazyListState() - val listBlockPosition by remember { - derivedStateOf { - listState.firstVisibleItemIndex / viewModel.listBlockSize - } - } - viewModel.onListBlockPosition(listBlockPosition) - val openWorksiteId by viewModel.openWorksiteId - if (openWorksiteId.second != 0L) { - openCase(openWorksiteId.first, openWorksiteId.second) - viewModel.openWorksiteId.value = Pair(0, 0) + if (openWorksiteId.worksiteId != EmptyWorksite.id) { + openCase(openWorksiteId.incidentId, openWorksiteId.worksiteId) + viewModel.openWorksiteId.value = ExistingWorksiteIdentifierNone } LazyColumn( @@ -108,24 +106,25 @@ internal fun SyncInsightsRoute( } items( - logs.count, - key = { it }, + pagingLogs.itemCount, + key = pagingLogs.itemKey { it.id }, contentType = { - if (logs.getLog(it)?.isContinuingLogType == true) { + if (pagingLogs.isContinuingLogType(it)) { "detail-log-item" } else { "one-line-log-item" } }, ) { index -> - val log = logs.getLog(index) + val log = pagingLogs[index] if (log == null) { Text( "$index", Modifier.listItemPadding(), ) } else { - val modifier = if (log.isContinuingLogType) { + val isContinuingLogType = pagingLogs.isContinuingLogType(index) + val modifier = if (isContinuingLogType) { Modifier .padding(start = 16.dp) .listItemBottomPadding() @@ -134,20 +133,33 @@ internal fun SyncInsightsRoute( .listItemPadding() .listItemTopPadding() } - Column(modifier.clickable { viewModel.onExpandLog(log.syncLog) }) { - SyncLogDetail(log) + Column(modifier.clickable { viewModel.onExpandLog(log) }) { + SyncLogDetail(log, isContinuingLogType) } } } + + if (pagingLogs.loadState.append is LoadState.Loading) { + item( + contentType = { "loading" }, + ) { + // TODO Loading indicator + } + } } } } +private fun LazyPagingItems.isContinuingLogType(index: Int): Boolean { + return index in 1..