From 209dcdc21daaefd6405efb40ec1e22561cd77eaf Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 25 May 2024 13:10:15 -0400 Subject: [PATCH 01/33] Guard against syncing data where worksite is not cached locally --- app/build.gradle.kts | 2 +- .../core/database/dao/WorkTypeDaoTest.kt | 4 ++- .../dao/WorkTypeTransferRequestDaoTest.kt | 3 ++- .../database/dao/WorksiteChangeDaoTest.kt | 2 +- .../dao/WorksiteChangeTransferDaoTest.kt | 3 +-- .../core/database/dao/WorksiteDaoTest.kt | 11 +++++++- .../dao/WorksiteFormDataFlagNoteTest.kt | 3 ++- .../core/database/dao/WorksiteSyncFillTest.kt | 2 +- .../core/database/dao/WorksiteWorkTypeTest.kt | 4 ++- .../core/database/dao/WorksiteDaoPlus.kt | 25 +++++++++++++------ 10 files changed, 42 insertions(+), 17 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4fe220db1..77045fa00 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 203 + val buildVersion = 204 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" 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/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)) + } } } } From 1576501e520bf2dcbaf1f54f77b9353f29dd2c45 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 29 May 2024 14:51:04 -0400 Subject: [PATCH 02/33] Set account ID for crash reporting --- .../sandbox/SandboxApplication.kt | 2 + .../crisiscleanup/MainActivityViewModel.kt | 16 ++-- .../log/CrisisCleanupAppLogger.kt | 4 + .../core/common/log/AppLogger.kt | 2 + .../crisiscleanup/core/database/TestUtil.kt | 96 +++++++++++-------- 5 files changed, 72 insertions(+), 48 deletions(-) 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/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/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..bf2b4e462 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 { 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() From b540ab2947c472fa182921e3391d46d49137ba04 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 29 May 2024 15:10:22 -0400 Subject: [PATCH 03/33] Update dependencies --- .gitignore | 2 ++ gradle/libs.versions.toml | 27 ++++++++++++++------------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index 8a9ccabef..29ac6deec 100644 --- a/.gitignore +++ b/.gitignore @@ -289,3 +289,5 @@ core/database/schemas/com.crisiscleanup.core.database.TestCrisisCleanupDatabase # Direnv !.envrc .envrc.local + +.crashlytics diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 916e18e70..9fc71b329 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,22 +3,22 @@ accompanist = "0.32.0" androidDesugarJdkLibs = "2.0.4" # AGP and tools should be updated together androidGradlePlugin = "8.3.2" -androidTools = "31.3.2" +androidTools = "31.4.1" androidMapsUtil = "2.3.0" androidMapsUtilKtx = "3.4.0" -androidMaterial = "1.11.0" +androidMaterial = "1.12.0" androidxActivity = "1.9.0" -androidxAppCompat = "1.6.1" +androidxAppCompat = "1.7.0" androidxBrowser = "1.8.0" androidxCamera = "1.3.3" -androidxComposeBom = "2024.04.01" +androidxComposeBom = "2024.05.00" androidxComposeCompiler = "1.5.12" androidxComposeMaterial3 = "1.2.1" androidxComposeRuntimeTracing = "1.0.0-beta01" androidxConstraintLayout = "1.1.0-alpha13" -androidxCore = "1.13.0" +androidxCore = "1.13.1" androidxCoreSplashscreen = "1.0.1" -androidxDataStore = "1.1.0" +androidxDataStore = "1.1.1" androidxEspresso = "3.5.1" androidxHiltNavigationCompose = "1.2.0" androidxLifecycle = "2.7.0" @@ -34,17 +34,17 @@ androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" androidxTracing = "1.2.0" androidxUiAutomator = "2.3.0" -androidxWindowManager = "1.2.0" +androidxWindowManager = "1.3.0" androidxWork = "2.9.0" apacheCommonsText = "1.10.0" coil = "2.6.0" dependencyGuard = "0.4.3" -firebaseBom = "32.8.1" -firebaseCrashlyticsPlugin = "2.9.9" +firebaseBom = "33.0.0" +firebaseCrashlyticsPlugin = "3.0.1" firebasePerfPlugin = "1.4.2" gmsPlugin = "4.4.1" googleMapsCompose = "2.9.1" -googlePlaces = "3.4.0" +googlePlaces = "3.5.0" hilt = "2.51" hiltExt = "1.2.0" jacoco = "0.8.12" @@ -61,9 +61,9 @@ mockk = "1.13.5" moduleGraph = "2.5.0" okhttp = "4.12.0" philJayRrule = "1.0.3" -playServicesAuth = "21.1.0" -playServicesAuthPhone = "18.0.2" -playServicesLocation = "21.2.0" +playServicesAuth = "21.2.0" +playServicesAuthPhone = "18.1.0" +playServicesLocation = "21.3.0" playServicesMaps = "18.2.0" protobuf = "3.25.2" protobufPlugin = "0.9.4" @@ -139,6 +139,7 @@ coil-kt-svg = { group = "io.coil-kt", name = "coil-svg", version.ref = "coil" } firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" } firebase-performance = { group = "com.google.firebase", name = "firebase-perf-ktx" } google-maps-compose = { group = "com.google.maps.android", name = "maps-compose", version.ref = "googleMapsCompose" } google-places = { group = "com.google.android.libraries.places", name = "places", version.ref = "googlePlaces" } From d8dea9c4fc73abaeeeeffef25a79b1e04ca83630 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 29 May 2024 15:16:51 -0400 Subject: [PATCH 04/33] Attempt to configure Crashlytics symbols upload --- app/build.gradle.kts | 15 ++++++++++++++- app/proguard-rules.pro | 7 ++++++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 77045fa00..4d6b5e8a6 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 = 204 + val buildVersion = 208 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. @@ -166,6 +176,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..e1436aa8f 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -27,4 +27,9 @@ # 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. From 71555604f44e689c96e8ec2420f02ce2eab689fa Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 29 May 2024 17:31:33 -0400 Subject: [PATCH 05/33] Add list data models and network requests --- .../core/common/di/LoggersModule.kt | 49 ++++++++------ .../core/common/log/AppLogger.kt | 1 + .../crisiscleanup/core/data/ListsSyncer.kt | 65 +++++++++++++++++++ .../crisiscleanup/core/data/di/DataModule.kt | 5 ++ .../network/CrisisCleanupNetworkDataSource.kt | 6 ++ .../core/network/model/NetworkList.kt | 39 +++++++++++ .../core/network/retrofit/DataApiClient.kt | 10 +++ 7 files changed, 154 insertions(+), 21 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkList.kt 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 bf2b4e462..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 @@ -27,6 +27,7 @@ enum class CrisisCleanupLoggers { Cases, Incidents, Language, + Lists, Media, Navigation, Network, 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..93d145984 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt @@ -0,0 +1,65 @@ +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.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 javax.inject.Inject +import kotlin.coroutines.cancellation.CancellationException + +interface ListsSyncer { + suspend fun sync() +} + +// TODO Test coverage + +class AccountListsSyncer @Inject constructor( + private val networkDataSource: CrisisCleanupNetworkDataSource, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, +) : ListsSyncer { + override suspend fun sync() = coroutineScope { + var networkCount = 0 + var requestingCount = 0 + val cachedLists = mutableListOf() + try { + while (networkCount == 0 || requestingCount < networkCount) { + logger.logDebug("Get lists $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() + } + } catch (e: Exception) { + if (e is CancellationException) { + throw e + } + + logger.logException(e) + } + + logger.logDebug("Save lists ${cachedLists.size}") + } +} \ No newline at end of file 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..d16543f7d 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 @@ -226,4 +228,7 @@ interface DataInternalModule { fun providesIncidentOrganizationsNetworkDataCache( cache: IncidentOrganizationsDataFileCache, ): IncidentOrganizationsDataCache + + @Binds + fun bindsListsSyncer(syncer: AccountListsSyncer): ListsSyncer } 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..f99a4ea8f 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,7 @@ 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.NetworkListsResult import com.crisiscleanup.core.network.model.NetworkLocation import com.crisiscleanup.core.network.model.NetworkOrganizationShort import com.crisiscleanup.core.network.model.NetworkOrganizationsResult @@ -124,4 +125,9 @@ interface CrisisCleanupNetworkDataSource { suspend fun getProfile(accessToken: String): NetworkUserProfile? suspend fun getRequestRedeployIncidentIds(): Set + + suspend fun getLists( + limit: Int = 100, + offset: Int? = null, + ): NetworkListsResult } 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..eee1228a9 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkList.kt @@ -0,0 +1,39 @@ +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?, +) 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..a0169b433 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,7 @@ 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.NetworkListsResult import com.crisiscleanup.core.network.model.NetworkLocationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsResult import com.crisiscleanup.core.network.model.NetworkOrganizationsSearchResult @@ -244,6 +245,13 @@ 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 } private val worksiteCoreDataFields = listOf( @@ -470,4 +478,6 @@ 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) } From 6b199b122c2eae0624a1ba08e531293e7e238261 Mon Sep 17 00:00:00 2001 From: hue Date: Sun, 2 Jun 2024 17:07:06 -0400 Subject: [PATCH 06/33] Add list data and networking operations --- .gitignore | 2 + app/build.gradle.kts | 1 + .../navigation/CrisisCleanupNavHost.kt | 6 + .../core/appnav/RouteConstant.kt | 2 + core/data/build.gradle.kts | 1 + .../crisiscleanup/core/data/ListsSyncer.kt | 15 +- .../crisiscleanup/core/data/di/DataModule.kt | 7 + .../core/data/model/NetworkList.kt | 24 + .../core/data/repository/ListDataRefresher.kt | 36 + .../core/data/repository/ListsRepository.kt | 54 + .../core/data/repository/SyncLogRepository.kt | 49 +- core/database/build.gradle.kts | 2 + .../41.json | 2995 +++++++++++++++++ .../database/TestCrisisCleanupDatabase.kt | 2 + .../core/database/CrisisCleanupDatabase.kt | 7 +- .../core/database/dao/ListDao.kt | 72 + .../core/database/dao/ListDaoPlus.kt | 41 + .../core/database/dao/SyncLogDao.kt | 5 +- .../core/database/di/DaoModule.kt | 3 + .../core/database/model/ListEntity.kt | 65 + .../core/database/model/PopulatedList.kt | 46 + .../core/model/data/CrisisCleanupList.kt | 74 + .../crisiscleanup/core/model/data/Worksite.kt | 4 +- feature/lists/.gitignore | 1 + feature/lists/build.gradle.kts | 18 + feature/lists/src/main/AndroidManifest.xml | 1 + .../crisiscleanuplists/ListsViewModel.kt | 69 + .../navigation/ListsNavigation.kt | 22 + .../crisiscleanuplists/ui/ListsScreen.kt | 43 + .../crisiscleanup/feature/menu/MenuScreen.kt | 10 + .../feature/menu/MenuViewModel.kt | 2 + .../feature/menu/navigation/MenuNavigation.kt | 2 + feature/syncinsights/build.gradle.kts | 3 + .../syncinsights/SyncInsightsViewModel.kt | 83 +- .../syncinsights/ui/SyncInsightsScreen.kt | 52 +- gradle/libs.versions.toml | 5 + settings.gradle.kts | 1 + 37 files changed, 3705 insertions(+), 120 deletions(-) create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkList.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/repository/ListDataRefresher.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/repository/ListsRepository.kt create mode 100644 core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDaoPlus.kt create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt create mode 100644 core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedList.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt create mode 100644 feature/lists/.gitignore create mode 100644 feature/lists/build.gradle.kts create mode 100644 feature/lists/src/main/AndroidManifest.xml create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/navigation/ListsNavigation.kt create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt diff --git a/.gitignore b/.gitignore index 29ac6deec..c92f6d59b 100644 --- a/.gitignore +++ b/.gitignore @@ -291,3 +291,5 @@ core/database/schemas/com.crisiscleanup.core.database.TestCrisisCleanupDatabase .envrc.local .crashlytics + +app/dependencies \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4d6b5e8a6..97ef9e9f8 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -125,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) diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 30d069901..e5fca4406 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -30,6 +30,8 @@ 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.dashboard.navigation.dashboardScreen import com.crisiscleanup.feature.mediamanage.navigation.viewSingleImageScreen import com.crisiscleanup.feature.mediamanage.navigation.viewWorksiteImagesScreen @@ -97,6 +99,8 @@ fun CrisisCleanupNavHost( val openUserFeedback = remember(navController) { { navController.navigateToUserFeedback() } } + val openLists = remember(navController) { { navController.navigateToLists() } } + val openSyncLogs = remember(navController) { { navController.navigateToSyncInsights() } } val navToCaseAddFlagNonEditing = @@ -134,6 +138,7 @@ fun CrisisCleanupNavHost( teamScreen() menuScreen( openAuthentication = openAuthentication, + openLists = openLists, openInviteTeammate = openInviteTeammate, openRequestRedeploy = openRequestRedeploy, openUserFeedback = openUserFeedback, @@ -144,6 +149,7 @@ fun CrisisCleanupNavHost( inviteTeammateScreen(onBack) requestRedeployScreen(onBack) userFeedbackScreen(onBack) + listsScreen(onBack) 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..072f0c690 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,6 @@ 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" } 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/ListsSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt index 93d145984..1c3051a30 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt @@ -3,11 +3,13 @@ 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 @@ -19,15 +21,21 @@ interface ListsSyncer { 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) { - logger.logDebug("Get lists $requestingCount $networkCount") val result = networkDataSource.getLists(1000, requestingCount) result.errors?.tryThrowException() @@ -52,14 +60,17 @@ class AccountListsSyncer @Inject constructor( ensureActive() } + + listsRepository.syncLists(cachedLists) } catch (e: Exception) { if (e is CancellationException) { throw e } logger.logException(e) + } finally { + syncGuard.set(false) } - logger.logDebug("Save lists ${cachedLists.size}") } } \ No newline at end of file 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 d16543f7d..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 @@ -28,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 @@ -36,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 @@ -204,6 +206,11 @@ interface DataModule { fun bindsWorksiteImageRepository( repository: OfflineFirstWorksiteImageRepository, ): WorksiteImageRepository + + @Binds + fun bindsListRepository( + repository: CrisisCleanupListsRepository, + ): ListsRepository } @Module 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..b6bd3bcc9 --- /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.joinToString(","), + shared = shared, + permissions = permissions, + incidentId = incident, +) \ No newline at end of file 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..5e883be96 --- /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) + } + } +} \ No newline at end of file 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..66a5cf13f --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListsRepository.kt @@ -0,0 +1,54 @@ +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.split +import com.crisiscleanup.core.data.model.asEntity +import com.crisiscleanup.core.database.dao.ListDao +import com.crisiscleanup.core.database.dao.ListDaoPlus +import com.crisiscleanup.core.database.model.PopulatedList +import com.crisiscleanup.core.database.model.asExternalModel +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.network.model.NetworkList +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import javax.inject.Inject + +interface ListsRepository { + fun streamIncidentLists(incidentId: Long): Flow> + + fun pageLists(): Flow> + + suspend fun syncLists(lists: List) +} + +class CrisisCleanupListsRepository @Inject constructor( + private val listDao: ListDao, + private val listDaoPlus: ListDaoPlus, +) : 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 pageLists() = listPager.flow.map { + it.map(PopulatedList::asExternalModel) + } + + 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) + } +} \ No newline at end of file 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/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..8305a6159 --- /dev/null +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json @@ -0,0 +1,2995 @@ +{ + "formatVersion": 1, + "database": { + "version": 41, + "identityHash": "af880eea87bbc76fc30f4d0bfed8632b", + "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", + "unique": false, + "columnNames": [ + "model" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_model` ON `${TABLE_NAME}` (`model`)" + }, + { + "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, 'af880eea87bbc76fc30f4d0bfed8632b')" + ] + } +} \ 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/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt index e8bb4b44b..dea055f4e 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 = 41, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -146,6 +149,7 @@ 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), ], exportSchema = true, ) @@ -182,4 +186,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/ListDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt new file mode 100644 index 000000000..78d29ca5a --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDao.kt @@ -0,0 +1,72 @@ +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") + fun streamIncidentLists(incidentId: Long): Flow> + + @Transaction + @Query( + """ + SELECT * + FROM lists + ORDER BY updated_at DESC + """, + ) + fun pageLists(): PagingSource + + @Transaction + @Query("DELETE FROM lists WHERE network_id IN(:networkIds)") + fun deleteLists(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..3cf55f04d --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/ListDaoPlus.kt @@ -0,0 +1,41 @@ +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 (l in upsertLists) { + val insertId = listDao.insertIgnoreList(l) + if (insertId < 0) { + // TODO Do not update where local changes exist and were made after updatedAt + listDao.syncUpdateList( + networkId = l.networkId, + updatedBy = l.updatedBy, + updatedAt = l.updatedAt, + parent = l.parent, + name = l.name, + description = l.description ?: "", + listOrder = l.listOrder, + tags = l.tags ?: "", + model = l.model, + objectIds = l.objectIds, + shared = l.shared, + permissions = l.permissions, + incident = l.incidentId, + ) + } + } + + listDao.deleteLists(deleteNetworkIds) + } +} \ No newline at end of file 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/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/ListEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt new file mode 100644 index 000000000..b67029ef8 --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt @@ -0,0 +1,65 @@ +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"]), + 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?, +) \ No newline at end of file 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..0fa6cc34a --- /dev/null +++ b/core/database/src/main/java/com/crisiscleanup/core/database/model/PopulatedList.kt @@ -0,0 +1,46 @@ +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.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, + parentNetworkId = parent, + name = name, + description = description ?: "", + listOrder = listOrder, + tags = tags, + model = listModelFromLiteral(model), + objectIds = numericObjectIds, + shared = listShareFromLiteral(shared), + permission = listPermissionFromLiteral(permissions), + incident = incident?.let { + IncidentIdNameType( + id = incident.id, + name = incident.name, + shortName = incident.shortName, + disasterLiteral = incident.type, + ) + } ?: EmptyList.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..1ee03cdc9 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt @@ -0,0 +1,74 @@ +package com.crisiscleanup.core.model.data + +import kotlinx.datetime.Instant + +data class CrisisCleanupList( + val id: Long, + val updatedAt: Instant, + 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 incident: IncidentIdNameType?, +) + +val EmptyList = CrisisCleanupList( + id = 0, + updatedAt = Instant.fromEpochSeconds(0), + parentNetworkId = null, + name = "", + description = "", + listOrder = null, + tags = null, + model = ListModel.None, + objectIds = emptyList(), + shared = ListShare.Private, + permission = ListPermission.Read, + 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/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/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..83bee25e4 --- /dev/null +++ b/feature/lists/build.gradle.kts @@ -0,0 +1,18 @@ +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(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..00ad81eb6 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -0,0 +1,69 @@ +package com.crisiscleanup.feature.crisiscleanuplists + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.cachedIn +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.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.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn +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, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, +) : ViewModel() { + val incidentLists = incidentSelector.incident + .filter { it != EmptyIncident } + .flatMapLatest { + listsRepository.streamIncidentLists(it.id) + } + .stateIn( + scope = viewModelScope, + initialValue = emptyList(), + started = SharingStarted.WhileSubscribed(1_000), + ) + + val isRefreshingData = MutableStateFlow(false) + + val allLists = listsRepository.pageLists() + .flowOn(ioDispatcher) + .cachedIn(viewModelScope) + + init { + refreshLists() + } + + fun refreshLists(force: Boolean = false) { + if (isRefreshingData.value) { + return + } + isRefreshingData.value = true + + viewModelScope.launch(ioDispatcher) { + try { + listDataRefresher.refreshListData(force) + } finally { + isRefreshingData.value = false + } + } + } +} \ No newline at end of file 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..d79e6a898 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/navigation/ListsNavigation.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup.feature.crisiscleanuplists.navigation + +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.crisiscleanup.core.appnav.RouteConstant +import com.crisiscleanup.feature.crisiscleanuplists.ui.ListsRoute + +fun NavController.navigateToLists(navOptions: NavOptions? = null) { + this.navigate(RouteConstant.LISTS_ROUTE, navOptions) +} + +fun NavGraphBuilder.listsScreen( + onBack: () -> Unit, +) { + composable(route = RouteConstant.LISTS_ROUTE) { + ListsRoute( + onBack = onBack, + ) + } +} 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..9691668ee --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt @@ -0,0 +1,43 @@ +package com.crisiscleanup.feature.crisiscleanuplists.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.collectAsLazyPagingItems +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.CrisisCleanupButton +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.feature.crisiscleanuplists.ListsViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ListsRoute( + onBack: () -> Unit = {}, + viewModel: ListsViewModel = hiltViewModel(), +) { + val incidentLists by viewModel.incidentLists.collectAsStateWithLifecycle() + val allLists = viewModel.allLists.collectAsLazyPagingItems() + + val t = LocalAppTranslator.current + // TODO Tabs for current incident lists and all lists + // TODO Pull to refresh + Column { + TopAppBarBackAction( + title = t("~~Lists"), + onAction = onBack, + ) + + Text(t("~~Lists are currently read-only. Manage lists using Crisis Cleanup on the browser")) + + Text("Incident lists ${incidentLists.size}. All ${allLists.itemCount} ") + + CrisisCleanupButton( + text = t("Refresh lists"), + onClick = { viewModel.refreshLists(true) }, + ) + } +} \ No newline at end of file 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/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index 6156cc64f..f6bd9b009 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -14,6 +14,7 @@ import com.crisiscleanup.core.common.sync.SyncPuller import com.crisiscleanup.core.commonassets.R import com.crisiscleanup.core.commonassets.getDisasterIcon import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.ListsSyncer import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountDataRepository @@ -49,6 +50,7 @@ class MenuViewModel @Inject constructor( private val databaseVersionProvider: DatabaseVersionProvider, @ApplicationScope private val externalScope: CoroutineScope, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, + private val listsSyncer: ListsSyncer, ) : ViewModel() { val isDebuggable = appEnv.isDebuggable val isNotProduction = appEnv.isNotProduction 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..efbb8e39f 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,13 +3,13 @@ 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.repository.SyncLogRepository import com.crisiscleanup.core.data.repository.WorksiteChangeRepository @@ -18,20 +18,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 +49,11 @@ class SyncInsightsViewModel @Inject constructor( it.isNotEmpty() } - private val logSliceCount = 80 - private val logSliceCountHalf = (logSliceCount * 0.5f).toInt() - val listBlockSize = 20 - - 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 +63,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) { @@ -131,33 +87,4 @@ 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, -) +} \ No newline at end of file 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..56fa153ff 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,25 @@ 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.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.SyncLog import com.crisiscleanup.feature.syncinsights.SyncInsightsViewModel -import com.crisiscleanup.feature.syncinsights.SyncLogItem @Composable internal fun SyncInsightsRoute( @@ -54,16 +57,9 @@ 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) @@ -108,24 +104,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 +131,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.. Date: Sun, 2 Jun 2024 20:51:53 -0400 Subject: [PATCH 07/33] Start on lists screen --- .../core/commoncase/ui/IncidentHeaderView.kt | 104 ++++++++ .../core/designsystem/component/TopAppBar.kt | 3 +- .../designsystem/icon/CrisisCleanupIcons.kt | 2 + .../feature/caseeditor/ui/CaseIncidentView.kt | 65 +---- feature/lists/build.gradle.kts | 2 + .../crisiscleanuplists/ListsViewModel.kt | 2 + .../crisiscleanuplists/ui/ListsScreen.kt | 226 +++++++++++++++++- 7 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 core/commoncase/src/main/java/com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt 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..bb6a67048 --- /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, + ) + } + } +} \ No newline at end of file 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/icon/CrisisCleanupIcons.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt index dc4a5cb15..9afd3e13a 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 @@ -19,6 +19,7 @@ import androidx.compose.material.icons.filled.Directions 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 @@ -71,6 +72,7 @@ object CrisisCleanupIcons { val ExpandLess = icons.ExpandLess val ExpandMore = icons.ExpandMore val Help = Icons.AutoMirrored.Filled.HelpOutline + val Info = icons.Info val Location = icons.LocationOn val Mail = icons.Mail val Minus = icons.Remove 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/lists/build.gradle.kts b/feature/lists/build.gradle.kts index 83bee25e4..1f545eb8a 100644 --- a/feature/lists/build.gradle.kts +++ b/feature/lists/build.gradle.kts @@ -10,6 +10,8 @@ android { dependencies { implementation(projects.core.data) + implementation(projects.core.commonassets) + implementation(projects.core.commoncase) implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.runtime.ktx) 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 index 00ad81eb6..e1d2dd0ac 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -31,6 +31,8 @@ class ListsViewModel @Inject constructor( @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, ) : ViewModel() { + val currentIncident = incidentSelector.incident + val incidentLists = incidentSelector.incident .filter { it != EmptyIncident } .flatMapLatest { 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 index 9691668ee..b6d94a5bc 100644 --- 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 @@ -1,43 +1,245 @@ package com.crisiscleanup.feature.crisiscleanuplists.ui +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +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.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.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems +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.CrisisCleanupButton -import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +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.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemModifier +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.Incident import com.crisiscleanup.feature.crisiscleanuplists.ListsViewModel +import kotlinx.coroutines.launch +import kotlin.math.min -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun ListsRoute( onBack: () -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), ) { + val t = LocalAppTranslator.current + val incidentLists by viewModel.incidentLists.collectAsStateWithLifecycle() val allLists = viewModel.allLists.collectAsLazyPagingItems() - val t = LocalAppTranslator.current - // TODO Tabs for current incident lists and all lists - // TODO Pull to refresh + val tabTitles = remember(incidentLists, allLists) { + 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 { - TopAppBarBackAction( + TopAppBarBackCaretAction( title = t("~~Lists"), onAction = onBack, + actions = { + CrisisCleanupIconButton( + imageVector = CrisisCleanupIcons.Info, + onClick = { + showReadOnlyDescription = true + }, + enabled = true, + ) + }, ) - Text(t("~~Lists are currently read-only. Manage lists using Crisis Cleanup on the browser")) + val pullRefreshState = rememberPullToRefreshState() + if (pullRefreshState.isRefreshing) { + LaunchedEffect(true) { + viewModel.refreshLists(true) + pullRefreshState.endRefresh() + } + } + + val pagerState = rememberPagerState( + initialPage = 0, + initialPageOffsetFraction = 0f, + ) { tabTitles.size } + 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"), + ) + } + } + + Box( + Modifier + .weight(1f) + .nestedScroll(pullRefreshState.nestedScrollConnection), + ) { + HorizontalPager(state = pagerState) { pagerIndex -> + when (pagerIndex) { + 0 -> IncidentListsView( + incidentLists, + currentIncident, + ) + + 1 -> AllListsView( + allLists, + ) + } + } + + BusyIndicatorFloatingTopCenter(isLoading) - Text("Incident lists ${incidentLists.size}. All ${allLists.itemCount} ") + val pullProgress = min(pullRefreshState.progress * 1.5f, 1.0f) + PullToRefreshContainer( + modifier = Modifier + .align(Alignment.TopCenter) + .alpha(pullProgress), + state = pullRefreshState, + ) + } + } - CrisisCleanupButton( - text = t("Refresh lists"), - onClick = { viewModel.refreshLists(true) }, + 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 on the 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, +) { + val t = LocalAppTranslator.current + + val listState = rememberLazyListState() + LazyColumn( + Modifier.fillMaxSize(), + state = listState, + ) { + 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 { + item { + Text("${incidentLists.size} lists") + } + } + } +} + +@Composable +private fun AllListsView( + allLists: LazyPagingItems, +) { + val listState = rememberLazyListState() + LazyColumn( + Modifier.fillMaxSize(), + state = listState, + ) { + item { + Text("${allLists.itemCount} lists") + } + } } \ No newline at end of file From 8b5461967180c4b4b4c9a4e491c2f5573c551a1a Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 3 Jun 2024 15:20:43 -0400 Subject: [PATCH 08/33] Render list summaries for select --- .../designsystem/icon/CrisisCleanupIcons.kt | 8 ++ .../core/designsystem/theme/StyleModifier.kt | 5 + .../model/CrisisCleanupList.kt | 22 +++ .../crisiscleanuplists/ui/ListsScreen.kt | 129 +++++++++++++++++- 4 files changed, 159 insertions(+), 5 deletions(-) create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/model/CrisisCleanupList.kt 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 9afd3e13a..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,7 +16,9 @@ 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 @@ -26,6 +29,7 @@ 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 @@ -71,14 +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..7150531ee 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( + space = 8.dp, + alignment = Alignment.CenterVertically, +) val listItemSpacedByHalf = Arrangement.spacedBy(8.dp) fun Modifier.listItemHeight() = this.heightIn(min = 56.dp) 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/ui/ListsScreen.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ListsScreen.kt index b6d94a5bc..d9d72f448 100644 --- 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 @@ -1,14 +1,18 @@ 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 @@ -28,11 +32,15 @@ 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 @@ -41,14 +49,20 @@ 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.component.actionHeight 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.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.Incident import com.crisiscleanup.feature.crisiscleanuplists.ListsViewModel +import com.crisiscleanup.feature.crisiscleanuplists.model.ListIcon import kotlinx.coroutines.launch import kotlin.math.min @@ -56,6 +70,7 @@ import kotlin.math.min @Composable fun ListsRoute( onBack: () -> Unit = {}, + onOpenList: (CrisisCleanupList) -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), ) { val t = LocalAppTranslator.current @@ -148,10 +163,12 @@ fun ListsRoute( 0 -> IncidentListsView( incidentLists, currentIncident, + onOpenList, ) 1 -> AllListsView( allLists, + onOpenList, ) } } @@ -191,6 +208,7 @@ fun ListsRoute( private fun IncidentListsView( incidentLists: List, incident: Incident, + onOpenList: (CrisisCleanupList) -> Unit = {}, ) { val t = LocalAppTranslator.current @@ -222,8 +240,20 @@ private fun IncidentListsView( ) } } else { - item { - Text("${incidentLists.size} lists") + items( + incidentLists.size, + key = { incidentLists[it].id }, + contentType = { "list-item" }, + ) { index -> + val list = incidentLists[index] + ListItemSummaryView( + list, + Modifier + .clickable { + onOpenList(list) + } + .then(listItemModifier), + ) } } } @@ -231,15 +261,104 @@ private fun IncidentListsView( @Composable private fun AllListsView( - allLists: LazyPagingItems, + pagingLists: LazyPagingItems, + onOpenList: (CrisisCleanupList) -> Unit = {}, ) { val listState = rememberLazyListState() LazyColumn( Modifier.fillMaxSize(), state = listState, ) { - item { - Text("${allLists.itemCount} lists") + + 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 +private fun ListItemSummaryView( + list: CrisisCleanupList, + modifier: Modifier = Modifier, + showIncident: Boolean = false, +) { + Column( + modifier.actionHeight(), + verticalArrangement = listItemCenterSpacedByHalf, + ) { + Row(horizontalArrangement = listItemSpacedByHalf) { + 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, + ) + } + } + + Text( + "${list.name} (${list.objectIds.size})", + style = LocalFontStyles.current.header3, + ) + Spacer(Modifier.weight(1f)) + Text(list.updatedAt.relativeTime) + } + + val incidentName = list.incident?.shortName ?: "" + val description = list.description.trim() + if (incidentName.isNotBlank() || description.isNotBlank()) { + Row { + if (description.isNotBlank()) { + Text( + list.description, + Modifier.weight(1f), + ) + } + + if (showIncident) { + if (description.isBlank()) { + Spacer(Modifier.weight(1f)) + } + list.incident?.shortName?.let { incidentName -> + Text(incidentName) + } + } + } } } } \ No newline at end of file From 140fd005a93a5ae0c0501d19e8d81af52a64bba5 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 3 Jun 2024 15:38:05 -0400 Subject: [PATCH 09/33] Alert when selected list (type) is not yet supported --- .../crisiscleanuplists/ui/ListsScreen.kt | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) 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 index d9d72f448..398dada19 100644 --- 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 @@ -60,7 +60,9 @@ 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 @@ -153,6 +155,22 @@ fun ListsRoute( } } + 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) @@ -163,12 +181,12 @@ fun ListsRoute( 0 -> IncidentListsView( incidentLists, currentIncident, - onOpenList, + filterOnOpenList, ) 1 -> AllListsView( allLists, - onOpenList, + filterOnOpenList, ) } } @@ -183,6 +201,23 @@ fun ListsRoute( 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) { From 152024be480c3787e75a054d553af72be4501cc8 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 3 Jun 2024 17:42:00 -0400 Subject: [PATCH 10/33] Navigate to view single list --- .../navigation/CrisisCleanupNavHost.kt | 4 +- .../core/appnav/RouteConstant.kt | 1 + .../crisiscleanuplists/ViewListViewModel.kt | 19 +++++++ .../navigation/ListsNavigation.kt | 53 +++++++++++++++++-- .../crisiscleanuplists/ui/ListsScreen.kt | 2 +- .../crisiscleanuplists/ui/ViewListScreen.kt | 29 ++++++++++ 6 files changed, 103 insertions(+), 5 deletions(-) create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt create mode 100644 feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index e5fca4406..9c50458bd 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -32,6 +32,7 @@ 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.viewListScreen import com.crisiscleanup.feature.dashboard.navigation.dashboardScreen import com.crisiscleanup.feature.mediamanage.navigation.viewSingleImageScreen import com.crisiscleanup.feature.mediamanage.navigation.viewWorksiteImagesScreen @@ -149,7 +150,8 @@ fun CrisisCleanupNavHost( inviteTeammateScreen(onBack) requestRedeployScreen(onBack) userFeedbackScreen(onBack) - listsScreen(onBack) + listsScreen(navController, onBack) + viewListScreen(onBack) 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 072f0c690..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 @@ -51,4 +51,5 @@ object RouteConstant { 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/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..75bc5db18 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -0,0 +1,19 @@ +package com.crisiscleanup.feature.crisiscleanuplists + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import com.crisiscleanup.feature.crisiscleanuplists.navigation.ViewListArgs +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class ViewListViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val viewListArgs = ViewListArgs(savedStateHandle) + + // TODO Make private + val listId = viewListArgs.listId + + // TODO Load list data or show error +} \ No newline at end of file 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 index d79e6a898..a4ff98069 100644 --- 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 @@ -1,22 +1,69 @@ 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 com.crisiscleanup.core.appnav.RouteConstant +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.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(RouteConstant.LISTS_ROUTE, navOptions) + 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 = RouteConstant.LISTS_ROUTE) { + composable(route = LISTS_ROUTE) { + val onOpenList = remember(navController) { + { list: CrisisCleanupList -> + navController.navigateToViewList(list.id) + } + } + ListsRoute( onBack = onBack, + onOpenList = onOpenList, + ) + } +} + +fun NavGraphBuilder.viewListScreen( + onBack: () -> 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, ) } } 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 index 398dada19..5869101a5 100644 --- 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 @@ -70,7 +70,7 @@ import kotlin.math.min @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable -fun ListsRoute( +internal fun ListsRoute( onBack: () -> Unit = {}, onOpenList: (CrisisCleanupList) -> Unit = {}, viewModel: ListsViewModel = hiltViewModel(), 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..6e3527296 --- /dev/null +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt @@ -0,0 +1,29 @@ +package com.crisiscleanup.feature.crisiscleanuplists.ui + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.hilt.navigation.compose.hiltViewModel +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun ViewListRoute( + onBack: () -> Unit = {}, + viewModel: ViewListViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + + + Column { + // TODO Use title (name of list) from view model + TopAppBarBackAction( + title = t("~~List"), + onAction = onBack, + ) + Text("Single list ${viewModel.listId}") + } +} \ No newline at end of file From ed4998db484b10ad1ce74ba7abe4ad4509b6ae4d Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 3 Jun 2024 21:53:49 -0400 Subject: [PATCH 11/33] View list details --- .../core/data/repository/ListsRepository.kt | 6 ++ .../core/database/dao/ListDao.kt | 4 + .../crisiscleanup/core/model/data/Incident.kt | 1 + .../crisiscleanuplists/ViewListViewModel.kt | 58 +++++++++++- .../crisiscleanuplists/ui/ListsScreen.kt | 39 ++++---- .../crisiscleanuplists/ui/ViewListScreen.kt | 89 +++++++++++++++++-- 6 files changed, 170 insertions(+), 27 deletions(-) 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 index 66a5cf13f..eb34ad6f7 100644 --- 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 @@ -11,6 +11,7 @@ import com.crisiscleanup.core.database.dao.ListDaoPlus import com.crisiscleanup.core.database.model.PopulatedList 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.network.model.NetworkList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -21,6 +22,8 @@ interface ListsRepository { fun pageLists(): Flow> + fun streamList(listId: Long): Flow + suspend fun syncLists(lists: List) } @@ -42,6 +45,9 @@ class CrisisCleanupListsRepository @Inject constructor( 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 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 index 78d29ca5a..1e7b79146 100644 --- 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 @@ -17,6 +17,10 @@ interface ListDao { @Query("SELECT * FROM lists WHERE incident_id=:incidentId") fun streamIncidentLists(incidentId: Long): Flow> + @Transaction + @Query("SELECT * FROM lists WHERE id=:id") + fun streamList(id: Long): Flow + @Transaction @Query( """ 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..c92a361c7 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 @@ -73,4 +73,5 @@ data class IncidentIdNameType( val name: String, val shortName: String, val disasterLiteral: String, + val disaster: Disaster = disasterFromLiteral(disasterLiteral), ) 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 index 75bc5db18..1d71dee99 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -2,18 +2,70 @@ package com.crisiscleanup.feature.crisiscleanuplists import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.data.repository.ListsRepository +import com.crisiscleanup.core.model.data.CrisisCleanupList +import com.crisiscleanup.core.model.data.EmptyList import com.crisiscleanup.feature.crisiscleanuplists.navigation.ViewListArgs import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn import javax.inject.Inject @HiltViewModel class ViewListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + listsRepository: ListsRepository, + translator: KeyResourceTranslator, ) : ViewModel() { private val viewListArgs = ViewListArgs(savedStateHandle) - // TODO Make private - val listId = viewListArgs.listId + private val listId = viewListArgs.listId - // TODO Load list data or show error + val viewState = listsRepository.streamList(listId) + .map { list -> + if (list == EmptyList) { + val listNotFound = + translator("~~List was not found. Go back and try selecting the list again.") + return@map ViewListViewState.Error(listNotFound) + } + + ViewListViewState.Success(list) + } + .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), + ) + + init { + // TODO Load list data or show error + } +} + +sealed interface ViewListViewState { + data object Loading : ViewListViewState + + data class Success( + val list: CrisisCleanupList, + ) : ViewListViewState + + data class Error( + val message: String, + ) : ViewListViewState } \ No newline at end of file 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 index 5869101a5..b1f79fb77 100644 --- 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 @@ -339,6 +339,27 @@ private fun AllListsView( } } +@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 private fun ListItemSummaryView( list: CrisisCleanupList, @@ -350,21 +371,7 @@ private fun ListItemSummaryView( verticalArrangement = listItemCenterSpacedByHalf, ) { Row(horizontalArrangement = listItemSpacedByHalf) { - 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, - ) - } - } + ListIcon(list) Text( "${list.name} (${list.objectIds.size})", @@ -380,7 +387,7 @@ private fun ListItemSummaryView( Row { if (description.isNotBlank()) { Text( - list.description, + description, Modifier.weight(1f), ) } 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 index 6e3527296..70fdb0fcf 100644 --- 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 @@ -1,13 +1,29 @@ package com.crisiscleanup.feature.crisiscleanuplists.ui +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.material3.ExperimentalMaterial3Api import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier import androidx.hilt.navigation.compose.hiltViewModel -import com.crisiscleanup.core.designsystem.LocalAppTranslator +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.common.relativeTime +import com.crisiscleanup.core.commonassets.getDisasterIcon +import com.crisiscleanup.core.commoncase.ui.IncidentHeaderView +import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf +import com.crisiscleanup.core.model.data.CrisisCleanupList import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewModel +import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewState @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -15,15 +31,72 @@ internal fun ViewListRoute( onBack: () -> Unit = {}, viewModel: ViewListViewModel = hiltViewModel(), ) { - val t = LocalAppTranslator.current + val screenTitle by viewModel.screenTitle.collectAsStateWithLifecycle() + val viewState by viewModel.viewState.collectAsStateWithLifecycle() + Box(Modifier.fillMaxSize()) { + Column { + TopAppBarBackAction( + title = screenTitle, + onAction = onBack, + ) - Column { - // TODO Use title (name of list) from view model - TopAppBarBackAction( - title = t("~~List"), - onAction = onBack, + when (viewState) { + is ViewListViewState.Success -> { + val list = (viewState as ViewListViewState.Success).list + ListDetailsView(list) + } + + is ViewListViewState.Error -> { + Text((viewState as ViewListViewState.Error).message) + } + + else -> {} + } + } + + BusyIndicatorFloatingTopCenter(viewState is ViewListViewState.Loading) + } +} + +@Composable +private fun ListDetailsView( + list: CrisisCleanupList, +) { + list.incident?.let { incident -> + IncidentHeaderView( + Modifier, + incident.shortName, + getDisasterIcon(incident.disaster), + isSyncing = false, + ) + } + + Row( + listItemModifier, + horizontalArrangement = listItemSpacedByHalf, + ) { + ListIcon(list) + + Text( + list.name, + style = LocalFontStyles.current.header3, + ) + Spacer(Modifier.weight(1f)) + Text(list.updatedAt.relativeTime) + } + + val description = list.description.trim() + if (description.isNotBlank()) { + Text( + description, + listItemModifier, ) - Text("Single list ${viewModel.listId}") + } + + LazyColumn { + // TODO Load individual items + // Change incident + // Open to Case } } \ No newline at end of file From a65790335fd79d70cb9ada59625f33fbe3926b2e Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 3 Jun 2024 22:32:44 -0400 Subject: [PATCH 12/33] Refresh list from backend on view --- .../core/data/repository/ListsRepository.kt | 50 +++++++++++++++++++ .../core/database/dao/ListDao.kt | 4 ++ .../core/database/dao/ListDaoPlus.kt | 36 ++++++------- .../network/CrisisCleanupNetworkDataSource.kt | 3 ++ .../core/network/model/NetworkList.kt | 6 +++ .../core/network/retrofit/DataApiClient.kt | 16 ++++++ .../crisiscleanuplists/ListsViewModel.kt | 4 -- .../crisiscleanuplists/ViewListViewModel.kt | 9 +++- .../crisiscleanuplists/ui/ViewListScreen.kt | 8 --- 9 files changed, 106 insertions(+), 30 deletions(-) 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 index eb34ad6f7..8933cde42 100644 --- 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 @@ -4,14 +4,20 @@ 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.asEntity import com.crisiscleanup.core.database.dao.ListDao import com.crisiscleanup.core.database.dao.ListDaoPlus +import com.crisiscleanup.core.database.model.ListEntity import com.crisiscleanup.core.database.model.PopulatedList 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.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.model.CrisisCleanupNetworkException import com.crisiscleanup.core.network.model.NetworkList import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map @@ -25,11 +31,15 @@ interface ListsRepository { fun streamList(listId: Long): Flow suspend fun syncLists(lists: List) + + suspend fun refreshList(id: Long) } class CrisisCleanupListsRepository @Inject constructor( private val listDao: ListDao, private val listDaoPlus: ListDaoPlus, + private val networkDataSource: CrisisCleanupNetworkDataSource, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, ) : ListsRepository { private val listPager = Pager( config = PagingConfig(pageSize = 30), @@ -57,4 +67,44 @@ class CrisisCleanupListsRepository @Inject constructor( val invalidNetworkIds = invalidLists.map(NetworkList::id).toSet() listDaoPlus.syncUpdateLists(listEntities, invalidNetworkIds) } + + private fun syncUpdateList(list: ListEntity) = 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, + ) + } + + override suspend fun refreshList(id: Long) { + listDao.getList(id)?.let { cachedList -> + if (cachedList.networkId > 0) { + // TODO Skip update where locally modified + try { + networkDataSource.getList(cachedList.networkId)?.asEntity()?.let { updateList -> + syncUpdateList(updateList) + } + } catch (e: Exception) { + (e as? CrisisCleanupNetworkException)?.statusCode?.let { code -> + if (code == 404) { + // TODO Handle. Delete outright or show as deleted on backend? + logger.logDebug("List does not exist on backend. Delete and take additional action") + return + } + } + logger.logException(e) + } + } + } + } } \ No newline at end of file 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 index 1e7b79146..e6d27ed54 100644 --- 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 @@ -21,6 +21,10 @@ interface ListDao { @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( """ 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 index 3cf55f04d..c7ab798db 100644 --- 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 @@ -14,25 +14,27 @@ class ListDaoPlus @Inject constructor( ) = db.withTransaction { val listDao = db.listDao() - for (l in upsertLists) { - val insertId = listDao.insertIgnoreList(l) + 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 - listDao.syncUpdateList( - networkId = l.networkId, - updatedBy = l.updatedBy, - updatedAt = l.updatedAt, - parent = l.parent, - name = l.name, - description = l.description ?: "", - listOrder = l.listOrder, - tags = l.tags ?: "", - model = l.model, - objectIds = l.objectIds, - shared = l.shared, - permissions = l.permissions, - incident = l.incidentId, - ) + 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, + ) + } } } 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 f99a4ea8f..85a801dc3 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,7 @@ 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 @@ -130,4 +131,6 @@ interface CrisisCleanupNetworkDataSource { limit: Int = 100, offset: Int? = null, ): NetworkListsResult + + suspend fun getList(id: Long): NetworkList? } 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 index eee1228a9..e565748d4 100644 --- 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 @@ -37,3 +37,9 @@ data class NetworkList( @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 a0169b433..47e1aa9da 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,8 @@ 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 @@ -252,6 +254,14 @@ private interface DataSourceApi { @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( @@ -480,4 +490,10 @@ class DataApiClient @Inject constructor( 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 + } } 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 index e1d2dd0ac..c06e48c6b 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -3,9 +3,6 @@ package com.crisiscleanup.feature.crisiscleanuplists import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import androidx.paging.cachedIn -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.data.IncidentSelector @@ -29,7 +26,6 @@ class ListsViewModel @Inject constructor( private val listDataRefresher: ListDataRefresher, private val listsRepository: ListsRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, ) : ViewModel() { val currentIncident = incidentSelector.incident 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 index 1d71dee99..976d3681a 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -4,14 +4,18 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers +import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.data.repository.ListsRepository import com.crisiscleanup.core.model.data.CrisisCleanupList import com.crisiscleanup.core.model.data.EmptyList 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.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel @@ -19,6 +23,7 @@ class ViewListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, listsRepository: ListsRepository, translator: KeyResourceTranslator, + @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val viewListArgs = ViewListArgs(savedStateHandle) @@ -54,7 +59,9 @@ class ViewListViewModel @Inject constructor( ) init { - // TODO Load list data or show error + viewModelScope.launch(ioDispatcher) { + listsRepository.refreshList(listId) + } } } 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 index 70fdb0fcf..e86accc8f 100644 --- 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 @@ -3,7 +3,6 @@ package com.crisiscleanup.feature.crisiscleanuplists.ui 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.material3.ExperimentalMaterial3Api @@ -18,7 +17,6 @@ import com.crisiscleanup.core.commonassets.getDisasterIcon import com.crisiscleanup.core.commoncase.ui.IncidentHeaderView import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction -import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf import com.crisiscleanup.core.model.data.CrisisCleanupList @@ -77,12 +75,6 @@ private fun ListDetailsView( horizontalArrangement = listItemSpacedByHalf, ) { ListIcon(list) - - Text( - list.name, - style = LocalFontStyles.current.header3, - ) - Spacer(Modifier.weight(1f)) Text(list.updatedAt.relativeTime) } From 7c0ba60c1217cc8faf49d64b84949973e3e48efd Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 7 Jun 2024 15:34:10 -0400 Subject: [PATCH 13/33] Refactor common Case classes --- .../crisiscleanup/core/commoncase}/CommonCaseConstants.kt | 0 .../crisiscleanup/core/commoncase}/TransferWorkTypeProvider.kt | 0 .../{ => com/crisiscleanup/core/commoncase}/WorksiteProvider.kt | 0 .../core/commoncase/ui/ExplainWrongLocationDialog.kt | 2 +- .../com/crisiscleanup/core/commoncase/ui/IncidentHeaderView.kt | 2 +- .../com/crisiscleanup/feature/caseeditor/ui/ViewCaseScreen.kt | 2 +- .../com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt | 2 +- 7 files changed, 4 insertions(+), 4 deletions(-) rename core/commoncase/src/main/java/{ => com/crisiscleanup/core/commoncase}/CommonCaseConstants.kt (100%) rename core/commoncase/src/main/java/{ => com/crisiscleanup/core/commoncase}/TransferWorkTypeProvider.kt (100%) rename core/commoncase/src/main/java/{ => com/crisiscleanup/core/commoncase}/WorksiteProvider.kt (100%) 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 index bb6a67048..e7ee2d154 100644 --- 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 @@ -101,4 +101,4 @@ private fun IncidentHeaderPendingSyncPreview() { ) } } -} \ No newline at end of file +} 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/ui/CasesScreenTableView.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/ui/CasesScreenTableView.kt index 54ae8ccff..89fd701e3 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 @@ -52,9 +52,9 @@ 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 From 96d2a31a8b7596f167fa34e9994262f5a0427f22 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 7 Jun 2024 15:34:29 -0400 Subject: [PATCH 14/33] Refactor Case info views --- .../designsystem/component/PhoneCallDialog.kt | 48 +++++++ .../designsystem/component/WorksiteViews.kt | 112 +++++++++++++++ .../feature/cases/ui/CasesScreenTableView.kt | 133 +++--------------- 3 files changed, 182 insertions(+), 111 deletions(-) create mode 100644 core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/PhoneCallDialog.kt create mode 100644 core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/WorksiteViews.kt 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/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/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 89fd701e3..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,9 +47,6 @@ 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.model.addressQuery import com.crisiscleanup.core.commoncase.oneDecimalFormat @@ -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"), - ) - } - } - } - } - } -} From f1417c82dc948d978c105d13b137e2d737377af1 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 7 Jun 2024 15:41:25 -0400 Subject: [PATCH 15/33] View lists in detail --- .../core/data/IncidentWorksitesFullSyncer.kt | 3 +- .../crisiscleanup/core/data/ListsSyncer.kt | 3 +- .../core/data/model/NetworkList.kt | 2 +- .../data/repository/CaseHistoryRepository.kt | 26 +- .../core/data/repository/ListDataRefresher.kt | 2 +- .../core/data/repository/ListsRepository.kt | 146 +++++++++- .../core/data/repository/UsersRepository.kt | 25 ++ .../core/database/dao/IncidentDao.kt | 4 + .../core/database/dao/ListDao.kt | 10 +- .../core/database/dao/ListDaoPlus.kt | 4 +- .../core/database/dao/PersonContactDao.kt | 4 + .../core/database/dao/WorksiteDao.kt | 5 + .../core/database/model/ListEntity.kt | 2 +- .../core/database/model/PopulatedList.kt | 1 + .../core/model/data/CrisisCleanupList.kt | 2 + .../network/CrisisCleanupNetworkDataSource.kt | 2 + .../core/network/retrofit/DataApiClient.kt | 13 + .../network/retrofit/RegisterApiClient.kt | 4 +- .../crisiscleanuplists/ListsViewModel.kt | 4 +- .../crisiscleanuplists/ViewListViewModel.kt | 13 +- .../crisiscleanuplists/ui/ListsScreen.kt | 7 +- .../crisiscleanuplists/ui/ViewListScreen.kt | 267 +++++++++++++++++- .../syncinsights/SyncInsightsViewModel.kt | 2 +- 23 files changed, 494 insertions(+), 57 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt b/core/data/src/main/java/com/crisiscleanup/core/data/IncidentWorksitesFullSyncer.kt index 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 index 1c3051a30..90bfbb6c8 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/ListsSyncer.kt @@ -71,6 +71,5 @@ class AccountListsSyncer @Inject constructor( } finally { syncGuard.set(false) } - } -} \ No newline at end of file +} 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 index b6bd3bcc9..4fedcb5bd 100644 --- 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 @@ -21,4 +21,4 @@ internal fun NetworkList.asEntity() = ListEntity( shared = shared, permissions = permissions, incidentId = incident, -) \ No newline at end of file +) 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/ListDataRefresher.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ListDataRefresher.kt index 5e883be96..5d9e67cb4 100644 --- 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 @@ -33,4 +33,4 @@ class ListDataRefresher @Inject constructor( logger.logException(e) } } -} \ No newline at end of file +} 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 index 8933cde42..b871445bb 100644 --- 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 @@ -8,19 +8,37 @@ 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.ListEntity +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 { @@ -33,12 +51,21 @@ interface ListsRepository { 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 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( @@ -90,15 +117,15 @@ class CrisisCleanupListsRepository @Inject constructor( 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 { updateList -> - syncUpdateList(updateList) - } + networkDataSource.getList(cachedList.networkId) + ?.asEntity() + ?.let { syncUpdateList(it) } } catch (e: Exception) { (e as? CrisisCleanupNetworkException)?.statusCode?.let { code -> if (code == 404) { - // TODO Handle. Delete outright or show as deleted on backend? - logger.logDebug("List does not exist on backend. Delete and take additional action") + listDao.deleteList(id) return } } @@ -107,4 +134,111 @@ class CrisisCleanupListsRepository @Inject constructor( } } } -} \ No newline at end of file + + override suspend fun getListObjectData(list: CrisisCleanupList): Map { + 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) { + try { + usersRepository.queryUpdateUsers(objectIds) + 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) { + // TODO Validate incident exists locally as well + 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/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/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 index e6d27ed54..06ea36d60 100644 --- 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 @@ -25,6 +25,10 @@ interface ListDao { @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( """ @@ -35,9 +39,13 @@ interface ListDao { ) 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 deleteLists(networkIds: Set) + fun deleteListsByNetworkIds(networkIds: Set) @Transaction @Insert(onConflict = OnConflictStrategy.IGNORE) 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 index c7ab798db..ce2350d3a 100644 --- 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 @@ -38,6 +38,6 @@ class ListDaoPlus @Inject constructor( } } - listDao.deleteLists(deleteNetworkIds) + listDao.deleteListsByNetworkIds(deleteNetworkIds) } -} \ No newline at end of file +} 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/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/model/ListEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/ListEntity.kt index b67029ef8..60a50a8ba 100644 --- 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 @@ -62,4 +62,4 @@ data class ListEntity( val permissions: String, @ColumnInfo("incident_id") val incidentId: Long?, -) \ No newline at end of file +) 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 index 0fa6cc34a..2f8e83f6d 100644 --- 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 @@ -25,6 +25,7 @@ fun PopulatedList.asExternalModel() = with(entity) { CrisisCleanupList( id = id, updatedAt = updatedAt, + networkId = networkId, parentNetworkId = parent, name = name, description = description ?: "", 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 index 1ee03cdc9..f1cc1c5b8 100644 --- 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 @@ -5,6 +5,7 @@ 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, @@ -20,6 +21,7 @@ data class CrisisCleanupList( val EmptyList = CrisisCleanupList( id = 0, updatedAt = Instant.fromEpochSeconds(0), + networkId = 0, parentNetworkId = null, name = "", description = "", 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 85a801dc3..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 @@ -133,4 +133,6 @@ interface CrisisCleanupNetworkDataSource { ): 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/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 47e1aa9da..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 @@ -496,4 +496,17 @@ class DataApiClient @Inject constructor( 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/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt index c06e48c6b..72c1e79ff 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -37,7 +37,7 @@ class ListsViewModel @Inject constructor( .stateIn( scope = viewModelScope, initialValue = emptyList(), - started = SharingStarted.WhileSubscribed(1_000), + started = SharingStarted.WhileSubscribed(3_000), ) val isRefreshingData = MutableStateFlow(false) @@ -64,4 +64,4 @@ class ListsViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 index 976d3681a..b9a706937 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -13,6 +13,7 @@ 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.stateIn import kotlinx.coroutines.launch @@ -33,12 +34,17 @@ class ViewListViewModel @Inject constructor( .map { list -> if (list == EmptyList) { val listNotFound = - translator("~~List was not found. Go back and try selecting the list again.") + translator("~~List was not found. It is likely deleted.") return@map ViewListViewState.Error(listNotFound) } - ViewListViewState.Success(list) + val lookup = listsRepository.getListObjectData(list) + val objectData = list.objectIds.map { id -> + lookup[id] + } + ViewListViewState.Success(list, objectData) } + .flowOn(ioDispatcher) .stateIn( scope = viewModelScope, initialValue = ViewListViewState.Loading, @@ -70,9 +76,10 @@ sealed interface ViewListViewState { data class Success( val list: CrisisCleanupList, + val objectData: List, ) : ViewListViewState data class Error( val message: String, ) : ViewListViewState -} \ No newline at end of file +} 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 index b1f79fb77..73f9dcf82 100644 --- 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 @@ -80,7 +80,7 @@ internal fun ListsRoute( val incidentLists by viewModel.incidentLists.collectAsStateWithLifecycle() val allLists = viewModel.allLists.collectAsLazyPagingItems() - val tabTitles = remember(incidentLists, allLists) { + val tabTitles = remember(incidentLists, allLists.itemCount) { val incidentText = t("~~Incident") val allText = t("~~All") val listCount = allLists.itemCount @@ -304,7 +304,6 @@ private fun AllListsView( Modifier.fillMaxSize(), state = listState, ) { - items( pagingLists.itemCount, key = pagingLists.itemKey { it.id }, @@ -361,7 +360,7 @@ internal fun ListIcon( } @Composable -private fun ListItemSummaryView( +internal fun ListItemSummaryView( list: CrisisCleanupList, modifier: Modifier = Modifier, showIncident: Boolean = false, @@ -403,4 +402,4 @@ private fun ListItemSummaryView( } } } -} \ No newline at end of file +} 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 index e86accc8f..891f4fa7e 100644 --- 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 @@ -1,25 +1,52 @@ 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.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter +import com.crisiscleanup.core.designsystem.component.LinkifyEmailText +import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText +import com.crisiscleanup.core.designsystem.component.PhoneNumbersDialog 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.component.actionHeight +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemCenterSpacedByHalf 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.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 @@ -41,8 +68,14 @@ internal fun ViewListRoute( when (viewState) { is ViewListViewState.Success -> { - val list = (viewState as ViewListViewState.Success).list - ListDetailsView(list) + val successState = (viewState as ViewListViewState.Success) + val list = successState.list + val objectData = successState.objectData + ListDetailsView( + list, + objectData, + rememberKey = viewModel, + ) } is ViewListViewState.Error -> { @@ -60,7 +93,11 @@ internal fun ViewListRoute( @Composable private fun ListDetailsView( list: CrisisCleanupList, + objectData: List, + rememberKey: Any, ) { + val t = LocalAppTranslator.current + list.incident?.let { incident -> IncidentHeaderView( Modifier, @@ -86,9 +123,227 @@ private fun ListDetailsView( ) } - LazyColumn { + 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()) } } + PhoneNumbersDialog( + parsedNumbers = phoneNumberList, + onCloseDialog = clearPhoneNumbers, + ) + // TODO Load individual items - // Change incident - // Open to Case + LazyColumn { + when (list.model) { + ListModel.Incident -> { + incidentItems(objectData) + } + + ListModel.List -> { + // TODO Open to List + listItems(objectData) + } + + ListModel.Organization -> { + organizationItems(objectData) + } + + ListModel.User -> { + userItems(objectData) + } + + ListModel.Worksite -> { + // TODO Open to Case + worksiteItems( + objectData, + setPhoneNumberList, + ) + } + + else -> {} + } + } + } +} + +@Composable +private fun MissingItem() { + Text( + LocalAppTranslator.current("~~Missing list data."), + listItemModifier, + ) +} + +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), + ) + } + } +} + +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 + .actionHeight() + .wrapContentHeight(align = Alignment.CenterVertically), + ) + } } -} \ No newline at end of file +} + +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 + .actionHeight(), + verticalArrangement = listItemCenterSpacedByHalf, + ) { + Text(contact.fullName) + if (contact.mobile.isNotBlank()) { + LinkifyPhoneText(contact.mobile) + } + if (contact.email.isNotBlank()) { + LinkifyEmailText(contact.email) + } + } + } + } +} + +private fun LazyListScope.worksiteItems( + 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) { + MissingItem() + } else { + val (fullAddress, geoQuery, locationQuery) = worksite.addressQuery + + Column( + Modifier + .clickable(onClick = { onOpenWorksite(worksite) }) + .then( + listItemModifier + .actionHeight(), + ), + 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/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt b/feature/syncinsights/src/main/java/com/crisiscleanup/feature/syncinsights/SyncInsightsViewModel.kt index efbb8e39f..236c8455b 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 @@ -87,4 +87,4 @@ class SyncInsightsViewModel @Inject constructor( } } } -} \ No newline at end of file +} From e86bcc619667e567452b88bd11dd2d9063425428 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 7 Jun 2024 15:42:55 -0400 Subject: [PATCH 16/33] Show proper message when registering by invite --- .../authentication/RequestOrgAccessViewModel.kt | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) 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") From f79aaf0c643b11eb5e52119f5c679e09e56c9a65 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 11 Jun 2024 12:28:53 -0400 Subject: [PATCH 17/33] Clear logger account ID on logout --- .../core/data/repository/CrisisCleanupAccountDataRepository.kt | 1 + 1 file changed, 1 insertion(+) 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 From 21ae23e17f6955b6aea98803fef51628dd50291c Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 11 Jun 2024 22:17:09 -0400 Subject: [PATCH 18/33] Update list schema and fix reference --- .../core/data/repository/ListsRepository.kt | 39 +++++++++---------- .../41.json | 16 +++++--- .../core/database/dao/ListDao.kt | 11 +++++- .../core/database/model/ListEntity.kt | 5 ++- .../crisiscleanuplists/ui/ViewListScreen.kt | 4 +- 5 files changed, 44 insertions(+), 31 deletions(-) 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 index b871445bb..21e74ffc4 100644 --- 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 @@ -18,7 +18,6 @@ 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.ListEntity import com.crisiscleanup.core.database.model.PopulatedIncident import com.crisiscleanup.core.database.model.PopulatedIncidentOrganization import com.crisiscleanup.core.database.model.PopulatedList @@ -95,24 +94,6 @@ class CrisisCleanupListsRepository @Inject constructor( listDaoPlus.syncUpdateLists(listEntities, invalidNetworkIds) } - private fun syncUpdateList(list: ListEntity) = 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, - ) - } - override suspend fun refreshList(id: Long) { listDao.getList(id)?.let { cachedList -> if (cachedList.networkId > 0) { @@ -121,7 +102,25 @@ class CrisisCleanupListsRepository @Inject constructor( try { networkDataSource.getList(cachedList.networkId) ?.asEntity() - ?.let { syncUpdateList(it) } + ?.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) { diff --git a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json index 8305a6159..6d8a671ab 100644 --- a/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json +++ b/core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/41.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 41, - "identityHash": "af880eea87bbc76fc30f4d0bfed8632b", + "identityHash": "23e4bda629525238defdb9a682b03d81", "entities": [ { "tableName": "work_type_statuses", @@ -2952,13 +2952,17 @@ "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_updated_at` ON `${TABLE_NAME}` (`updated_at` DESC)" }, { - "name": "index_lists_model", + "name": "index_lists_model_updated_at", "unique": false, "columnNames": [ - "model" + "model", + "updated_at" ], - "orders": [], - "createSql": "CREATE INDEX IF NOT EXISTS `index_lists_model` ON `${TABLE_NAME}` (`model`)" + "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", @@ -2989,7 +2993,7 @@ "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, 'af880eea87bbc76fc30f4d0bfed8632b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '23e4bda629525238defdb9a682b03d81')" ] } } \ No newline at end of file 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 index 06ea36d60..c4b1ae8f8 100644 --- 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 @@ -14,7 +14,14 @@ import kotlinx.datetime.Instant @Dao interface ListDao { @Transaction - @Query("SELECT * FROM lists WHERE incident_id=:incidentId") + @Query( + """ + SELECT * + FROM lists + WHERE incident_id=:incidentId + ORDER BY updated_at DESC + """, + ) fun streamIncidentLists(incidentId: Long): Flow> @Transaction @@ -67,7 +74,7 @@ interface ListDao { shared = :shared, permissions = :permissions, incident_id = :incident - WHERE network_id=:networkId AND local_global_uuid="" + WHERE network_id=:networkId AND local_global_uuid='' """, ) fun syncUpdateList( 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 index 60a50a8ba..ffb85724c 100644 --- 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 @@ -22,7 +22,10 @@ import kotlinx.datetime.Instant value = ["updated_at"], orders = [Order.DESC], ), - Index(value = ["model"]), + Index( + value = ["model", "updated_at"], + orders = [Order.DESC, Order.DESC], + ), Index(value = ["parent", "list_order"]), ], foreignKeys = [ 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 index 891f4fa7e..7e923f802 100644 --- 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 @@ -29,7 +29,7 @@ import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.LinkifyEmailText import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText -import com.crisiscleanup.core.designsystem.component.PhoneNumbersDialog +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 @@ -136,7 +136,7 @@ private fun ListDetailsView( } } val clearPhoneNumbers = remember(rememberKey) { { setPhoneNumberList(emptyList()) } } - PhoneNumbersDialog( + PhoneCallDialog( parsedNumbers = phoneNumberList, onCloseDialog = clearPhoneNumbers, ) From 0ea18542980d871e58497ad8c92afe8b9c1a074c Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 14 Jun 2024 19:52:07 -0400 Subject: [PATCH 19/33] Update list layouts and copy --- .../core/data/repository/ListsRepository.kt | 3 +- .../crisiscleanuplists/ui/ListsScreen.kt | 6 +- .../crisiscleanuplists/ui/ViewListScreen.kt | 60 ++++++++++--------- 3 files changed, 38 insertions(+), 31 deletions(-) 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 index 21e74ffc4..7ce5ffa04 100644 --- 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 @@ -199,8 +199,9 @@ class CrisisCleanupListsRepository @Inject constructor( var contactLookup = getContactLookup() if (contactLookup.size != objectIds.size) { + val userIds = objectIds.filter { !contactLookup.containsKey(it) } try { - usersRepository.queryUpdateUsers(objectIds) + usersRepository.queryUpdateUsers(userIds) contactLookup = getContactLookup() } catch (e: Exception) { logger.logException(e) 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 index 73f9dcf82..8fea686b2 100644 --- 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 @@ -270,7 +270,7 @@ private fun IncidentListsView( if (incidentLists.isEmpty()) { item(key = "static-text") { Text( - t("No lists have been created for this Incident."), + t("~~No lists have been created for this Incident."), listItemModifier, ) } @@ -380,7 +380,7 @@ internal fun ListItemSummaryView( Text(list.updatedAt.relativeTime) } - val incidentName = list.incident?.shortName ?: "" + val incidentName = if (showIncident) list.incident?.shortName ?: "" else "" val description = list.description.trim() if (incidentName.isNotBlank() || description.isNotBlank()) { Row { @@ -395,7 +395,7 @@ internal fun ListItemSummaryView( if (description.isBlank()) { Spacer(Modifier.weight(1f)) } - list.incident?.shortName?.let { incidentName -> + 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 index 7e923f802..65fdab839 100644 --- 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 @@ -98,31 +98,6 @@ private fun ListDetailsView( ) { val t = LocalAppTranslator.current - 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, - ) - } - if (objectData.isEmpty()) { Text( t("~~This list is not supported by the app or has no items."), @@ -141,8 +116,34 @@ private fun ListDetailsView( onCloseDialog = clearPhoneNumbers, ) - // TODO Load individual items LazyColumn { + 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) @@ -169,7 +170,11 @@ private fun ListDetailsView( ) } - else -> {} + else -> { + item { + Text(t("~~This list is not supported by the app.")) + } + } } } } @@ -228,6 +233,7 @@ private fun LazyListScope.listItems( onOpenList(list) } .then(listItemModifier), + true, ) } } From 98f52afa460cc89c4e5dfe5fc9adefe5b93fa86e Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 15 Jun 2024 17:59:37 -0400 Subject: [PATCH 20/33] Update from cross development --- .../java/com/crisiscleanup/core/common/PhoneNumberUtil.kt | 5 ++--- .../feature/crisiscleanuplists/ui/ViewListScreen.kt | 3 ++- 2 files changed, 4 insertions(+), 4 deletions(-) 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/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 index 65fdab839..4d150d0ac 100644 --- 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 @@ -42,6 +42,7 @@ 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.EmptyWorksite import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.model.data.IncidentOrganization import com.crisiscleanup.core.model.data.ListModel @@ -305,7 +306,7 @@ private fun LazyListScope.worksiteItems( contentType = { worksites[it]?.id ?: "missing-item" }, ) { val worksite = worksites[it] - if (worksite == null) { + if (worksite == null || worksite == EmptyWorksite) { MissingItem() } else { val (fullAddress, geoQuery, locationQuery) = worksite.addressQuery From 9e216d94c84f01f72420619c4c31efaf1856bbc3 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 21 Jun 2024 13:37:03 -0400 Subject: [PATCH 21/33] Open list in lists --- app/build.gradle.kts | 2 +- .../navigation/CrisisCleanupNavHost.kt | 11 ++++++++++- .../navigation/ListsNavigation.kt | 2 ++ .../crisiscleanuplists/ui/ViewListScreen.kt | 15 +++++++++++++-- 4 files changed, 26 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 97ef9e9f8..b1c716736 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ plugins { android { defaultConfig { - val buildVersion = 208 + val buildVersion = 209 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 9c50458bd..34ca1258e 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -32,6 +32,7 @@ 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 @@ -101,6 +102,11 @@ 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() } } @@ -151,7 +157,10 @@ fun CrisisCleanupNavHost( requestRedeployScreen(onBack) userFeedbackScreen(onBack) listsScreen(navController, onBack) - viewListScreen(onBack) + viewListScreen( + onBack, + openList = openList, + ) syncInsightsScreen(viewCase) resetPasswordScreen( 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 index a4ff98069..9f67e06de 100644 --- 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 @@ -52,6 +52,7 @@ fun NavGraphBuilder.listsScreen( fun NavGraphBuilder.viewListScreen( onBack: () -> Unit, + openList: (Long) -> Unit, ) { composable( route = "$VIEW_LIST_ROUTE?$LIST_ID_ARG={$LIST_ID_ARG}", @@ -64,6 +65,7 @@ fun NavGraphBuilder.viewListScreen( ) { ViewListRoute( onBack = onBack, + onOpenList = openList, ) } } 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 index 4d150d0ac..d25b3e845 100644 --- 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 @@ -55,6 +55,7 @@ import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewState @Composable internal fun ViewListRoute( onBack: () -> Unit = {}, + onOpenList: (Long) -> Unit = {}, viewModel: ViewListViewModel = hiltViewModel(), ) { val screenTitle by viewModel.screenTitle.collectAsStateWithLifecycle() @@ -75,6 +76,7 @@ internal fun ViewListRoute( ListDetailsView( list, objectData, + onOpenList, rememberKey = viewModel, ) } @@ -95,6 +97,7 @@ internal fun ViewListRoute( private fun ListDetailsView( list: CrisisCleanupList, objectData: List, + onOpenList: (Long) -> Unit, rememberKey: Any, ) { val t = LocalAppTranslator.current @@ -117,6 +120,12 @@ private fun ListDetailsView( onCloseDialog = clearPhoneNumbers, ) + val openList = remember(rememberKey) { + { list: CrisisCleanupList -> + onOpenList(list.id) + } + } + LazyColumn { item { list.incident?.let { incident -> @@ -151,8 +160,10 @@ private fun ListDetailsView( } ListModel.List -> { - // TODO Open to List - listItems(objectData) + listItems( + objectData, + openList, + ) } ListModel.Organization -> { From 145bb5203af627cc900a62c452f714d3dabef8d1 Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 21 Jun 2024 17:26:24 -0400 Subject: [PATCH 22/33] Open Case when selected from list --- .../navigation/CrisisCleanupNavHost.kt | 8 +++ .../navigation/CaseEditorNavigation.kt | 5 ++ .../crisiscleanuplists/ViewListViewModel.kt | 52 ++++++++++++++- .../navigation/ListsNavigation.kt | 3 + .../crisiscleanuplists/ui/ViewListScreen.kt | 64 +++++++++++++++++-- .../syncinsights/SyncInsightsViewModel.kt | 9 ++- .../syncinsights/ui/SyncInsightsScreen.kt | 8 ++- 7 files changed, 137 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 34ca1258e..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 @@ -91,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 = @@ -160,6 +167,7 @@ fun CrisisCleanupNavHost( viewListScreen( onBack, openList = openList, + openWorksite = openViewCase, ) syncInsightsScreen(viewCase) 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/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt index b9a706937..313014262 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -1,14 +1,24 @@ 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.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.data.repository.ListsRepository import com.crisiscleanup.core.model.data.CrisisCleanupList import com.crisiscleanup.core.model.data.EmptyList +import com.crisiscleanup.core.model.data.EmptyWorksite +import com.crisiscleanup.core.model.data.Worksite import com.crisiscleanup.feature.crisiscleanuplists.navigation.ViewListArgs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -23,7 +33,8 @@ import javax.inject.Inject class ViewListViewModel @Inject constructor( savedStateHandle: SavedStateHandle, listsRepository: ListsRepository, - translator: KeyResourceTranslator, + private val translator: KeyResourceTranslator, + @Logger(CrisisCleanupLoggers.Lists) private val logger: AppLogger, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { private val viewListArgs = ViewListArgs(savedStateHandle) @@ -64,11 +75,50 @@ class ViewListViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(3_000), ) + var isConfirmingOpenWorksite by mutableStateOf(false) + private set + var openWorksiteId by mutableStateOf(ExistingWorksiteIdentifierNone) + var openWorksiteError by mutableStateOf("") + init { viewModelScope.launch(ioDispatcher) { listsRepository.refreshList(listId) } } + + fun onOpenWorksite(worksite: Worksite) { + if (worksite == EmptyWorksite) { + return + } + + if (isConfirmingOpenWorksite) { + return + } + isConfirmingOpenWorksite = true + + viewModelScope.launch(ioDispatcher) { + try { + (viewState.value as? ViewListViewState.Success)?.list.let { list -> + if (list?.incident?.id == worksite.incidentId) { + openWorksiteId = ExistingWorksiteIdentifier( + incidentId = worksite.incidentId, + worksiteId = worksite.id, + ) + } 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 { 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 index 9f67e06de..43657d9d1 100644 --- 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 @@ -11,6 +11,7 @@ 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 @@ -53,6 +54,7 @@ fun NavGraphBuilder.listsScreen( fun NavGraphBuilder.viewListScreen( onBack: () -> Unit, openList: (Long) -> Unit, + openWorksite: (ExistingWorksiteIdentifier) -> Unit, ) { composable( route = "$VIEW_LIST_ROUTE?$LIST_ID_ARG={$LIST_ID_ARG}", @@ -66,6 +68,7 @@ fun NavGraphBuilder.viewListScreen( ViewListRoute( onBack = onBack, onOpenList = openList, + onOpenWorksite = openWorksite, ) } } 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 index d25b3e845..15a52e414 100644 --- 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 @@ -25,8 +25,12 @@ 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 @@ -42,6 +46,7 @@ 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 @@ -56,11 +61,24 @@ import com.crisiscleanup.feature.crisiscleanuplists.ViewListViewState 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 openWorksiteError = viewModel.openWorksiteError + + val showLoading = viewState is ViewListViewState.Loading || isConfirmingOpenWorksite Box(Modifier.fillMaxSize()) { Column { TopAppBarBackAction( @@ -77,6 +95,7 @@ internal fun ViewListRoute( list, objectData, onOpenList, + viewModel::onOpenWorksite, rememberKey = viewModel, ) } @@ -89,7 +108,22 @@ internal fun ViewListRoute( } } - BusyIndicatorFloatingTopCenter(viewState is ViewListViewState.Loading) + BusyIndicatorFloatingTopCenter(showLoading) + + 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, + ) + }, + ) + } } } @@ -98,6 +132,7 @@ private fun ListDetailsView( list: CrisisCleanupList, objectData: List, onOpenList: (Long) -> Unit, + onOpenWorksite: (Worksite) -> Unit, rememberKey: Any, ) { val t = LocalAppTranslator.current @@ -175,10 +210,11 @@ private fun ListDetailsView( } ListModel.Worksite -> { - // TODO Open to Case worksiteItems( + list.incident?.id ?: EmptyIncident.id, objectData, setPhoneNumberList, + onOpenWorksite, ) } @@ -194,10 +230,15 @@ private fun ListDetailsView( @Composable private fun MissingItem() { - Text( - LocalAppTranslator.current("~~Missing list data."), - listItemModifier, - ) + Box( + listItemModifier.actionHeight(), + contentAlignment = Alignment.CenterStart, + ) { + Text( + LocalAppTranslator.current("~~Missing list data."), + listItemModifier.actionHeight(), + ) + } } private fun LazyListScope.incidentItems( @@ -306,6 +347,7 @@ private fun LazyListScope.userItems( } private fun LazyListScope.worksiteItems( + incidentId: Long, listData: List, showPhoneNumbers: (List) -> Unit, onOpenWorksite: (Worksite) -> Unit = {}, @@ -319,6 +361,16 @@ private fun LazyListScope.worksiteItems( val worksite = worksites[it] if (worksite == null || worksite == EmptyWorksite) { MissingItem() + } else if (worksite.incidentId != incidentId) { + Box( + listItemModifier.actionHeight(), + 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 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 236c8455b..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 @@ -11,6 +11,8 @@ 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.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 @@ -49,7 +51,7 @@ class SyncInsightsViewModel @Inject constructor( it.isNotEmpty() } - val openWorksiteId = mutableStateOf(Pair(0L, 0L)) + val openWorksiteId = mutableStateOf(ExistingWorksiteIdentifierNone) val syncLogs = syncLogRepository.pageLogs() .flowOn(ioDispatcher) @@ -78,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) { 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 56fa153ff..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 @@ -22,12 +22,14 @@ 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 @@ -61,9 +63,9 @@ internal fun SyncInsightsRoute( val listState = rememberLazyListState() 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( From 6f2eaf6572ec0f7f40de964727b704791fb8647f Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 21 Jun 2024 19:29:51 -0400 Subject: [PATCH 23/33] Confirm changing incidents when selected Case in list differs from current --- .../core/domain/LoadSelectIncidents.kt | 11 ++- .../crisiscleanuplists/ViewListViewModel.kt | 87 +++++++++++++++++-- .../crisiscleanuplists/ui/ViewListScreen.kt | 32 ++++++- .../feature/menu/MenuViewModel.kt | 2 - 4 files changed, 118 insertions(+), 14 deletions(-) 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/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt index 313014262..d5853de89 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -12,10 +12,16 @@ 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.Worksite @@ -33,6 +39,10 @@ import javax.inject.Inject 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, @@ -41,6 +51,14 @@ class ViewListViewModel @Inject constructor( private val listId = viewListArgs.listId + private val loadSelectIncidents = LoadSelectIncidents( + incidentsRepository = incidentsRepository, + accountDataRepository = accountDataRepository, + incidentSelector = incidentSelector, + appPreferencesRepository = appPreferencesRepository, + coroutineScope = viewModelScope, + ) + val viewState = listsRepository.streamList(listId) .map { list -> if (list == EmptyList) { @@ -80,18 +98,57 @@ class ViewListViewModel @Inject constructor( 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("") + init { viewModelScope.launch(ioDispatcher) { listsRepository.refreshList(listId) } } - fun onOpenWorksite(worksite: Worksite) { - if (worksite == EmptyWorksite) { + fun onConfirmChangeIncident() { + if (isChangingIncident) { return } - if (isConfirmingOpenWorksite) { + 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 @@ -99,11 +156,29 @@ class ViewListViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { try { (viewState.value as? ViewListViewState.Success)?.list.let { list -> - if (list?.incident?.id == worksite.incidentId) { - openWorksiteId = ExistingWorksiteIdentifier( - incidentId = worksite.incidentId, + 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}") 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 index 15a52e414..c3ed72e3e 100644 --- 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 @@ -76,9 +76,12 @@ internal fun ViewListRoute( viewModel.openWorksiteId = ExistingWorksiteIdentifierNone } - val openWorksiteError = viewModel.openWorksiteError + val isChangingIncident = viewModel.isChangingIncident + + val indicateLoading = viewState is ViewListViewState.Loading || + isConfirmingOpenWorksite || + isChangingIncident - val showLoading = viewState is ViewListViewState.Loading || isConfirmingOpenWorksite Box(Modifier.fillMaxSize()) { Column { TopAppBarBackAction( @@ -108,8 +111,9 @@ internal fun ViewListRoute( } } - BusyIndicatorFloatingTopCenter(showLoading) + BusyIndicatorFloatingTopCenter(indicateLoading) + val openWorksiteError = viewModel.openWorksiteError if (openWorksiteError.isNotBlank()) { val closeDialog = remember(viewModel) { { viewModel.openWorksiteError = "" } } CrisisCleanupAlertDialog( @@ -124,6 +128,28 @@ internal fun ViewListRoute( }, ) } + + 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, + ) + }, + ) + } } } diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index f6bd9b009..6156cc64f 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -14,7 +14,6 @@ import com.crisiscleanup.core.common.sync.SyncPuller import com.crisiscleanup.core.commonassets.R import com.crisiscleanup.core.commonassets.getDisasterIcon import com.crisiscleanup.core.data.IncidentSelector -import com.crisiscleanup.core.data.ListsSyncer import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.CrisisCleanupAccountDataRepository @@ -50,7 +49,6 @@ class MenuViewModel @Inject constructor( private val databaseVersionProvider: DatabaseVersionProvider, @ApplicationScope private val externalScope: CoroutineScope, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, - private val listsSyncer: ListsSyncer, ) : ViewModel() { val isDebuggable = appEnv.isDebuggable val isNotProduction = appEnv.isNotProduction From 71edc4d422b6089da8f2ecbed531358a0658e92d Mon Sep 17 00:00:00 2001 From: hue Date: Fri, 21 Jun 2024 21:00:31 -0400 Subject: [PATCH 24/33] Render Incident Case label when presenting Incidents for changing --- .../core/data/model/NetworkIncident.kt | 1 + .../OfflineFirstIncidentsRepository.kt | 1 + .../42.json | 3006 +++++++++++++++++ .../core/database/dao/IncidentDaoTest.kt | 11 +- .../core/database/CrisisCleanupDatabase.kt | 3 +- .../core/database/model/IncidentEntity.kt | 2 + .../core/database/model/PopulatedIncident.kt | 1 + .../crisiscleanup/core/model/data/Incident.kt | 3 + .../core/network/model/NetworkIncident.kt | 2 + .../core/network/model/NetworkIncidentTest.kt | 5 + .../core/network/model/TestUtil.kt | 6 +- .../util/IterableStringSerializerTest.kt | 4 +- .../test/resources/getIncidentsSuccess.json | 5 + .../selectincident/SelectIncidentDialog.kt | 2 +- 14 files changed, 3045 insertions(+), 7 deletions(-) create mode 100644 core/database/schemas/com.crisiscleanup.core.database.CrisisCleanupDatabase/42.json diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkIncident.kt index 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/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 6961dfdc8..0fc4236fa 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", 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/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/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt b/core/database/src/main/java/com/crisiscleanup/core/database/CrisisCleanupDatabase.kt index dea055f4e..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 @@ -108,7 +108,7 @@ import com.crisiscleanup.core.database.util.InstantConverter IncidentWorksitesSecondarySyncStatsEntity::class, ListEntity::class, ], - version = 41, + version = 42, autoMigrations = [ AutoMigration(from = 1, to = 2), AutoMigration(from = 2, to = 3, spec = Schema2To3::class), @@ -150,6 +150,7 @@ import com.crisiscleanup.core.database.util.InstantConverter AutoMigration(from = 38, to = 39), AutoMigration(from = 39, to = 40), AutoMigration(from = 40, to = 41), + AutoMigration(from = 41, to = 42), ], exportSchema = true, ) diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt b/core/database/src/main/java/com/crisiscleanup/core/database/model/IncidentEntity.kt index 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/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/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 c92a361c7..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(), 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/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, ) From b38885d30d0283c5f483146ec6b6854a2c73e400 Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 22 Jun 2024 15:20:33 -0400 Subject: [PATCH 25/33] Cache list incident if not cached locally --- .../java/com/crisiscleanup/core/data/model/NetworkList.kt | 2 +- .../crisiscleanup/core/data/repository/ListsRepository.kt | 6 +++++- .../com/crisiscleanup/core/database/model/PopulatedList.kt | 2 ++ .../com/crisiscleanup/core/model/data/CrisisCleanupList.kt | 2 ++ .../com/crisiscleanup/core/network/model/NetworkList.kt | 2 +- .../feature/crisiscleanuplists/ViewListViewModel.kt | 5 +++-- 6 files changed, 14 insertions(+), 5 deletions(-) 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 index 4fedcb5bd..0b80e0367 100644 --- 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 @@ -17,7 +17,7 @@ internal fun NetworkList.asEntity() = ListEntity( listOrder = listOrder, tags = tags, model = model, - objectIds = objectIds.joinToString(","), + objectIds = (objectIds ?: emptyList()).joinToString(","), shared = shared, permissions = permissions, incidentId = incident, 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 index 7ce5ffa04..96a8d8668 100644 --- 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 @@ -59,6 +59,7 @@ class CrisisCleanupListsRepository @Inject constructor( 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, @@ -135,6 +136,10 @@ class CrisisCleanupListsRepository @Inject constructor( } 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) { @@ -218,7 +223,6 @@ class CrisisCleanupListsRepository @Inject constructor( var networkWorksiteLookup = getNetworkWorksiteLookup() if (networkWorksiteLookup.size != objectIds.size) { - // TODO Validate incident exists locally as well val worksiteIds = objectIds.filter { !networkWorksiteLookup.containsKey(it) } try { val syncedAt = Clock.System.now() 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 index 2f8e83f6d..6ade4ef69 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -35,6 +36,7 @@ fun PopulatedList.asExternalModel() = with(entity) { objectIds = numericObjectIds, shared = listShareFromLiteral(shared), permission = listPermissionFromLiteral(permissions), + incidentId = incidentId ?: EmptyIncident.id, incident = incident?.let { IncidentIdNameType( id = incident.id, 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 index f1cc1c5b8..78ed02855 100644 --- 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 @@ -15,6 +15,7 @@ data class CrisisCleanupList( val objectIds: List, val shared: ListShare, val permission: ListPermission, + val incidentId: Long, val incident: IncidentIdNameType?, ) @@ -31,6 +32,7 @@ val EmptyList = CrisisCleanupList( objectIds = emptyList(), shared = ListShare.Private, permission = ListPermission.Read, + incidentId = EmptyIncident.id, incident = IncidentIdNameType(id = EmptyIncident.id, "", "", ""), ) 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 index e565748d4..ef57cf226 100644 --- 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 @@ -30,7 +30,7 @@ data class NetworkList( val tags: String?, val model: String, @SerialName("object_ids") - val objectIds: List, + val objectIds: List?, val shared: String, val permissions: String, val incident: Long?, 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 index d5853de89..7cd278352 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -31,6 +31,7 @@ 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 @@ -60,11 +61,11 @@ class ViewListViewModel @Inject constructor( ) val viewState = listsRepository.streamList(listId) - .map { list -> + .mapLatest { list -> if (list == EmptyList) { val listNotFound = translator("~~List was not found. It is likely deleted.") - return@map ViewListViewState.Error(listNotFound) + return@mapLatest ViewListViewState.Error(listNotFound) } val lookup = listsRepository.getListObjectData(list) From 96207c5dbbfc14dcbae96558674cf8d215de45f4 Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 22 Jun 2024 16:33:01 -0400 Subject: [PATCH 26/33] Update list view layout and spacing --- .../core/designsystem/theme/StyleModifier.kt | 2 +- .../crisiscleanuplists/ViewListViewModel.kt | 7 ++++++- .../crisiscleanuplists/ui/ListsScreen.kt | 6 ++++-- .../crisiscleanuplists/ui/ViewListScreen.kt | 19 +++++++------------ 4 files changed, 18 insertions(+), 16 deletions(-) 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 7150531ee..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 @@ -22,7 +22,7 @@ val listItemModifier = Modifier val listItemHorizontalPadding = PaddingValues(horizontal = 16.dp) val listItemSpacedBy = Arrangement.spacedBy(16.dp) val listItemCenterSpacedByHalf = Arrangement.spacedBy( - space = 8.dp, + 8.dp, alignment = Alignment.CenterVertically, ) val listItemSpacedByHalf = Arrangement.spacedBy(8.dp) 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 index 7cd278352..6add92163 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -24,6 +24,7 @@ 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 @@ -69,7 +70,11 @@ class ViewListViewModel @Inject constructor( } val lookup = listsRepository.getListObjectData(list) - val objectData = list.objectIds.map { id -> + 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) 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 index 8fea686b2..2c5fcb646 100644 --- 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 @@ -49,11 +49,11 @@ 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.component.actionHeight 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 @@ -251,6 +251,7 @@ private fun IncidentListsView( LazyColumn( Modifier.fillMaxSize(), state = listState, + verticalArrangement = listItemSpacedByHalf, ) { if (incident != EmptyIncident) { item(key = "incident-info") { @@ -303,6 +304,7 @@ private fun AllListsView( LazyColumn( Modifier.fillMaxSize(), state = listState, + verticalArrangement = listItemSpacedByHalf, ) { items( pagingLists.itemCount, @@ -366,7 +368,7 @@ internal fun ListItemSummaryView( showIncident: Boolean = false, ) { Column( - modifier.actionHeight(), + modifier.listItemHeight(), verticalArrangement = listItemCenterSpacedByHalf, ) { Row(horizontalArrangement = listItemSpacedByHalf) { 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 index c3ed72e3e..c46346fe3 100644 --- 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 @@ -39,9 +39,9 @@ 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.component.actionHeight 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 @@ -187,7 +187,7 @@ private fun ListDetailsView( } } - LazyColumn { + LazyColumn(verticalArrangement = listItemCenterSpacedByHalf) { item { list.incident?.let { incident -> IncidentHeaderView( @@ -257,12 +257,11 @@ private fun ListDetailsView( @Composable private fun MissingItem() { Box( - listItemModifier.actionHeight(), + listItemModifier.listItemHeight(), contentAlignment = Alignment.CenterStart, ) { Text( LocalAppTranslator.current("~~Missing list data."), - listItemModifier.actionHeight(), ) } } @@ -334,7 +333,7 @@ private fun LazyListScope.organizationItems( Text( organization.name, listItemModifier - .actionHeight() + .listItemHeight() .wrapContentHeight(align = Alignment.CenterVertically), ) } @@ -356,8 +355,7 @@ private fun LazyListScope.userItems( MissingItem() } else { Column( - listItemModifier - .actionHeight(), + listItemModifier.listItemHeight(), verticalArrangement = listItemCenterSpacedByHalf, ) { Text(contact.fullName) @@ -389,7 +387,7 @@ private fun LazyListScope.worksiteItems( MissingItem() } else if (worksite.incidentId != incidentId) { Box( - listItemModifier.actionHeight(), + listItemModifier.listItemHeight(), contentAlignment = Alignment.CenterStart, ) { Text( @@ -403,10 +401,7 @@ private fun LazyListScope.worksiteItems( Column( Modifier .clickable(onClick = { onOpenWorksite(worksite) }) - .then( - listItemModifier - .actionHeight(), - ), + .then(listItemModifier.listItemHeight()), verticalArrangement = listItemCenterSpacedByHalf, ) { Text( From 6723f9376ad1879fb8b9914d869c6136ea939b22 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 24 Jun 2024 18:24:04 -0400 Subject: [PATCH 27/33] Open all lists tab when Incident has no lists --- .../core/data/repository/ListsRepository.kt | 4 ++++ .../core/database/dao/ListDao.kt | 4 ++++ .../crisiscleanuplists/ListsViewModel.kt | 24 +++++++++++++++++++ .../crisiscleanuplists/ViewListViewModel.kt | 4 ++-- .../crisiscleanuplists/ui/ListsScreen.kt | 15 ++++++++++-- gradle/libs.versions.toml | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- 7 files changed, 50 insertions(+), 7 deletions(-) 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 index 96a8d8668..b8e169fa9 100644 --- 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 @@ -43,6 +43,8 @@ import javax.inject.Inject interface ListsRepository { fun streamIncidentLists(incidentId: Long): Flow> + fun getIncidentListCount(incidentId: Long): Int + fun pageLists(): Flow> fun streamList(listId: Long): Flow @@ -78,6 +80,8 @@ class CrisisCleanupListsRepository @Inject constructor( 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) } 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 index c4b1ae8f8..5084db299 100644 --- 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 @@ -24,6 +24,10 @@ interface ListDao { ) 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 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 index 72c1e79ff..fc4d97202 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ListsViewModel.kt @@ -13,9 +13,12 @@ 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 @@ -46,6 +49,25 @@ class ListsViewModel @Inject constructor( .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() } @@ -59,6 +81,8 @@ class ListsViewModel @Inject constructor( 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 index 6add92163..939862150 100644 --- a/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt +++ b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ViewListViewModel.kt @@ -109,6 +109,7 @@ class ViewListViewModel @Inject constructor( private var openWorksiteChangeIncident = EmptyIncident private var pendingOpenWorksite = EmptyWorksite var changeIncidentConfirmMessage by mutableStateOf("") + private set init { viewModelScope.launch(ioDispatcher) { @@ -179,10 +180,9 @@ class ViewListViewModel @Inject constructor( openWorksiteChangeIncident = cachedIncident pendingOpenWorksite = worksite changeIncidentConfirmMessage = - translator("Would you like to change to {incident_name} and open Case {case_number}?") + 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 { 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 index 2c5fcb646..5629c0492 100644 --- 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 @@ -68,7 +68,10 @@ import com.crisiscleanup.feature.crisiscleanuplists.model.ListIcon import kotlinx.coroutines.launch import kotlin.math.min -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalFoundationApi::class, +) @Composable internal fun ListsRoute( onBack: () -> Unit = {}, @@ -123,6 +126,14 @@ internal fun ListsRoute( 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( @@ -223,7 +234,7 @@ internal fun ListsRoute( 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 on the browser.") + t("~~Lists (in this app) are currently read-only. Manage lists using Crisis Cleanup in a web browser.") CrisisCleanupAlertDialog( onDismissRequest = { showReadOnlyDescription = false }, title = readOnlyTitle, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2cd11aab2..578b034f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,8 +2,8 @@ accompanist = "0.32.0" androidDesugarJdkLibs = "2.0.4" # AGP and tools should be updated together -androidGradlePlugin = "8.3.2" -androidTools = "31.4.1" +androidGradlePlugin = "8.4.2" +androidTools = "31.5.0" androidMapsUtil = "2.3.0" androidMapsUtilKtx = "3.4.0" androidMaterial = "1.12.0" diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 585c28268..0d9fe8cfa 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Wed Mar 20 11:17:41 EDT 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME From 989b60b629b11ca82aef33fd82ab7bffc1440705 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 24 Jun 2024 20:01:59 -0400 Subject: [PATCH 28/33] Increase recent Incident query span --- .../core/data/repository/OfflineFirstIncidentsRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0fc4236fa..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 @@ -130,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 From f4f4ffd70ca20a7061b693d145a3964ff8a21d66 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 24 Jun 2024 22:17:56 -0400 Subject: [PATCH 29/33] Update dependencies --- gradle/libs.versions.toml | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 578b034f3..fe99790a1 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,8 +10,8 @@ androidMaterial = "1.12.0" androidxActivity = "1.9.0" androidxAppCompat = "1.7.0" androidxBrowser = "1.8.0" -androidxCamera = "1.3.3" -androidxComposeBom = "2024.05.00" +androidxCamera = "1.3.4" +androidxComposeBom = "2024.06.00" androidxComposeCompiler = "1.5.12" androidxComposeMaterial3 = "1.2.1" androidxComposeRuntimeTracing = "1.0.0-beta01" @@ -19,9 +19,9 @@ androidxConstraintLayout = "1.1.0-alpha13" androidxCore = "1.13.1" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.1.1" -androidxEspresso = "3.5.1" +androidxEspresso = "3.6.0" androidxHiltNavigationCompose = "1.2.0" -androidxLifecycle = "2.7.0" +androidxLifecycle = "2.8.2" androidxMacroBenchmark = "1.2.4" androidxMetrics = "1.0.0-beta01" androidxNavigation = "2.7.7" @@ -29,24 +29,24 @@ androidxPaging = "3.3.0" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxSecurityCrypto = "1.1.0-alpha06" -androidxTestCore = "1.5.0" -androidxTestExt = "1.1.5" -androidxTestRules = "1.5.0" -androidxTestRunner = "1.5.2" +androidxTestCore = "1.6.0" +androidxTestExt = "1.2.0" +androidxTestRules = "1.6.0" +androidxTestRunner = "1.6.0" androidxTracing = "1.2.0" androidxUiAutomator = "2.3.0" androidxWindowManager = "1.3.0" androidxWork = "2.9.0" apacheCommonsText = "1.10.0" coil = "2.6.0" -dependencyGuard = "0.4.3" -firebaseBom = "33.0.0" -firebaseCrashlyticsPlugin = "3.0.1" +dependencyGuard = "0.5.0" +firebaseBom = "33.1.1" +firebaseCrashlyticsPlugin = "3.0.2" firebasePerfPlugin = "1.4.2" -gmsPlugin = "4.4.1" +gmsPlugin = "4.4.2" googleMapsCompose = "2.9.1" googlePlaces = "3.5.0" -hilt = "2.51" +hilt = "2.51.1" hiltExt = "1.2.0" jacoco = "0.8.12" junit4 = "4.13.2" @@ -66,7 +66,7 @@ playServicesAuth = "21.2.0" playServicesAuthPhone = "18.1.0" playServicesLocation = "21.3.0" playServicesMaps = "18.2.0" -protobuf = "3.25.2" +protobuf = "4.26.0" protobufPlugin = "0.9.4" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" @@ -74,7 +74,7 @@ room = "2.6.1" secrets = "2.0.1" timeAgo = "4.0.3" truth = "1.4.2" -turbine = "1.0.0" +turbine = "1.1.0" zxing = "3.5.2" [libraries] From b5b4d774b4e94d2af60813a4a453e5eeb7fe28a2 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 25 Jun 2024 12:22:37 -0400 Subject: [PATCH 30/33] Ignore kotlin dir --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c92f6d59b..30d623fd4 100644 --- a/.gitignore +++ b/.gitignore @@ -292,4 +292,6 @@ core/database/schemas/com.crisiscleanup.core.database.TestCrisisCleanupDatabase .crashlytics -app/dependencies \ No newline at end of file +app/dependencies + +.kotlin \ No newline at end of file From b1b5e316d3012269eddb35d92d98ddcb7d459a41 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 25 Jun 2024 16:32:28 -0400 Subject: [PATCH 31/33] Cache searched Case when not cached --- .../OfflineFirstWorksitesRepository.kt | 8 ++++++++ .../core/data/repository/WorksitesRepository.kt | 2 ++ .../feature/caseeditor/ViewCaseViewModel.kt | 2 ++ .../feature/cases/CasesSearchViewModel.kt | 16 ++++++++++++++-- .../crisiscleanuplists/ui/ViewListScreen.kt | 4 ++-- 5 files changed, 28 insertions(+), 4 deletions(-) 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/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/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/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/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt b/feature/lists/src/main/kotlin/com/crisiscleanup/feature/crisiscleanuplists/ui/ViewListScreen.kt index c46346fe3..2d888e0bd 100644 --- 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 @@ -79,8 +79,8 @@ internal fun ViewListRoute( val isChangingIncident = viewModel.isChangingIncident val indicateLoading = viewState is ViewListViewState.Loading || - isConfirmingOpenWorksite || - isChangingIncident + isConfirmingOpenWorksite || + isChangingIncident Box(Modifier.fillMaxSize()) { Column { From 29464fcf696ef10605ea8fd211dcfc50f5c9f515 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 25 Jun 2024 17:47:37 -0400 Subject: [PATCH 32/33] Open to user registration screen when intercepting invite link --- .../com/crisiscleanup/ExternalIntentProcessor.kt | 3 +++ .../crisiscleanup/core/common/QueryParameter.kt | 14 ++++++++++++++ .../feature/authentication/ScanQrCodeViewModel.kt | 11 ++--------- 3 files changed, 19 insertions(+), 9 deletions(-) create mode 100644 core/common/src/main/java/com/crisiscleanup/core/common/QueryParameter.kt 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/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/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) { From 5e08d0294629db12ada2eb07a91fed433db3d6b8 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 25 Jun 2024 21:02:30 -0400 Subject: [PATCH 33/33] Add Proguard rule for dependency update --- app/build.gradle.kts | 2 +- app/proguard-rules.pro | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b1c716736..58a2fe118 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -15,7 +15,7 @@ plugins { android { defaultConfig { - val buildVersion = 209 + val buildVersion = 211 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index e1436aa8f..8231eee61 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -33,3 +33,6 @@ -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 { *; }