From 1bb0410492e23b5b85e14ea43fb2af376318f22e Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 9 Sep 2023 16:25:07 -0400 Subject: [PATCH 01/10] Sync pending data when app moves to the foreground --- app/build.gradle.kts | 2 +- .../java/com/crisiscleanup/MainActivity.kt | 3 + .../crisiscleanup/core/common/sync/Syncer.kt | 2 + .../java/com/crisiscleanup/sync/AppSyncer.kt | 27 +++--- .../sync/initializers/SyncWorkHelpers.kt | 17 +++- .../sync/workers/SyncWorksitesWorker.kt | 83 +++++++++++++++++++ sync/work/src/main/res/values/strings.xml | 7 +- 7 files changed, 125 insertions(+), 16 deletions(-) create mode 100644 sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3d60354e8..acea0b4a4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 149 + val buildVersion = 150 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.7.${buildVersion - 140}" diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 490cd6059..41ce722cd 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -37,6 +37,7 @@ import com.crisiscleanup.core.data.repository.EndOfLifeRepository import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.DarkThemeConfig +import com.crisiscleanup.sync.initializers.scheduleSyncWorksites import com.crisiscleanup.ui.CrisisCleanupApp import com.google.accompanist.systemuicontroller.rememberSystemUiController import com.google.android.gms.maps.MapsInitializer @@ -176,6 +177,8 @@ class MainActivity : ComponentActivity() { endOfLifeRepository.saveEndOfLifeData() appMetricsRepository.saveAppSupportInfo() + + scheduleSyncWorksites(this) } override fun onPause() { diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt index 085c93244..2b12534e8 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/sync/Syncer.kt @@ -29,8 +29,10 @@ interface SyncPusher { suspend fun syncPushWorksitesAsync(): Deferred fun stopPushWorksites() suspend fun syncPushMedia(): SyncResult + suspend fun syncPushWorksites(): SyncResult fun scheduleSyncMedia() + fun scheduleSyncWorksites() } sealed interface SyncResult { diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt index f235acb4f..0e72633d0 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/AppSyncer.kt @@ -20,6 +20,7 @@ import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.sync.SyncPull.determineSyncSteps import com.crisiscleanup.sync.SyncPull.executePlan import com.crisiscleanup.sync.initializers.scheduleSyncMedia +import com.crisiscleanup.sync.initializers.scheduleSyncWorksites import com.crisiscleanup.sync.initializers.scheduleSyncWorksitesFull import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException @@ -371,21 +372,25 @@ class AppSyncer @Inject constructor( override suspend fun syncPushWorksitesAsync(): Deferred { val deferred = applicationScope.async { - onSyncPreconditions(true)?.let { - return@async SyncResult.PreconditionsNotMet - } - - val isSyncAttempted = worksiteChangeRepository.syncWorksites() - return@async if (isSyncAttempted) { - SyncResult.Success("") - } else { - SyncResult.NotAttempted("Sync not attempted") - } + syncPushWorksites() } pushJob = deferred return deferred } + override suspend fun syncPushWorksites(): SyncResult { + onSyncPreconditions(true)?.let { + return SyncResult.PreconditionsNotMet + } + + val isSyncAttempted = worksiteChangeRepository.syncWorksites() + return if (isSyncAttempted) { + SyncResult.Success("") + } else { + SyncResult.NotAttempted("Sync not attempted") + } + } + override suspend fun syncPushMedia(): SyncResult { onSyncPreconditions(true)?.let { return SyncResult.PreconditionsNotMet @@ -404,4 +409,6 @@ class AppSyncer @Inject constructor( } override fun scheduleSyncMedia() = scheduleSyncMedia(context) + + override fun scheduleSyncWorksites() = scheduleSyncWorksites(context) } diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt index 2c564bcee..dbb4e4b91 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/initializers/SyncWorkHelpers.kt @@ -13,16 +13,19 @@ import com.crisiscleanup.sync.R import com.crisiscleanup.sync.workers.SyncMediaWorker import com.crisiscleanup.sync.workers.SyncWorker import com.crisiscleanup.sync.workers.SyncWorksitesFullWorker +import com.crisiscleanup.sync.workers.SyncWorksitesWorker import com.crisiscleanup.core.common.R as commonR internal const val SyncNotificationId = 0 -internal const val SyncMediaNotificationId = 0 -internal const val SyncWorksitesFullNotificationId = 0 +internal const val SyncMediaNotificationId = 1 +internal const val SyncWorksitesNotificationId = 2 +internal const val SyncWorksitesFullNotificationId = 3 private const val SyncNotificationChannelID = "SyncNotificationChannel" // These names should not be changed otherwise the app may have concurrent sync requests running internal const val SyncWorkName = "SyncWorkName" internal const val SyncMediaWorkName = "SyncMediaWorkName" +internal const val SyncWorksitesWorkName = "SyncWorksitesWorkName" internal const val SyncWorksitesFullWorkName = "SyncWorksitesFullWorkName" fun scheduleSync(context: Context) { @@ -46,6 +49,16 @@ fun scheduleSyncMedia(context: Context) { } } +fun scheduleSyncWorksites(context: Context) { + WorkManager.getInstance(context).apply { + enqueueUniqueWork( + SyncWorksitesWorkName, + ExistingWorkPolicy.KEEP, + SyncWorksitesWorker.oneTimeSyncWork(), + ) + } +} + fun scheduleSyncWorksitesFull(context: Context) { WorkManager.getInstance(context).apply { enqueueUniqueWork( diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt new file mode 100644 index 000000000..08025f290 --- /dev/null +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt @@ -0,0 +1,83 @@ +package com.crisiscleanup.sync.workers + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.tracing.traceAsync +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.OneTimeWorkRequest +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkerParameters +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers +import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.common.sync.SyncLogger +import com.crisiscleanup.core.common.sync.SyncPusher +import com.crisiscleanup.core.common.sync.SyncResult +import com.crisiscleanup.sync.R +import com.crisiscleanup.sync.initializers.SyncConstraints +import com.crisiscleanup.sync.initializers.SyncWorksitesNotificationId +import com.crisiscleanup.sync.initializers.channelNotificationManager +import com.crisiscleanup.sync.initializers.syncForegroundInfo +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.withContext + +@HiltWorker +class SyncWorksitesWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted workerParams: WorkerParameters, + private val syncPusher: SyncPusher, + private val syncLogger: SyncLogger, + @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, +) : CoroutineWorker(appContext, workerParams) { + override suspend fun getForegroundInfo() = appContext.syncForegroundInfo( + SyncWorksitesNotificationId, + text = appContext.getString(R.string.sync_cases_notification_text), + ) + + override suspend fun doWork() = withContext(ioDispatcher) { + traceAsync("WorksitesSync", 0) { + syncLogger.type = "background-sync-worksites" + syncLogger.log("Worksites sync start") + + val isSyncSuccess = awaitAll( + async { + val result = syncPusher.syncPushWorksites() + val isSuccess = result !is SyncResult.Error + if (isSuccess) { + syncPusher.scheduleSyncMedia() + } + isSuccess + }, + ).all { it } + + syncLogger + .log("Worksites sync end. success=$isSyncSuccess") + .flush() + + appContext.channelNotificationManager()?.cancel(SyncWorksitesNotificationId) + + if (isSyncSuccess) { + Result.success() + } else { + Result.retry() + } + } + } + + companion object { + fun oneTimeSyncWork(): OneTimeWorkRequest { + val data = Data.Builder() + .putAll(SyncWorksitesWorker::class.delegatedData()) + .build() + + return OneTimeWorkRequestBuilder() + .setConstraints(SyncConstraints) + .setInputData(data) + .build() + } + } +} \ No newline at end of file diff --git a/sync/work/src/main/res/values/strings.xml b/sync/work/src/main/res/values/strings.xml index 8b38b447f..2e27deafb 100644 --- a/sync/work/src/main/res/values/strings.xml +++ b/sync/work/src/main/res/values/strings.xml @@ -4,9 +4,10 @@ Background tasks for Crisis Cleanup Syncing data Syncing photos and images - Syncing incident cases + Syncing Cases + Syncing incident Cases Syncing %s - Saved %d/%d cases. - Saved %d/~%d cases. + Saved %d/%d Cases. + Saved %d/~%d Cases. Stop syncing \ No newline at end of file From 1b7dc2be08bcb07dc58a976fc543f87c749543be Mon Sep 17 00:00:00 2001 From: hue Date: Sun, 10 Sep 2023 09:22:30 -0400 Subject: [PATCH 02/10] Ignore users missing organizations --- .../core/data/model/NetworkPersonContact.kt | 12 ++++++------ .../core/data/repository/CaseHistoryRepository.kt | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkPersonContact.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkPersonContact.kt index afd4157c1..443c2cdaa 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkPersonContact.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkPersonContact.kt @@ -22,18 +22,18 @@ fun NetworkPersonContact.asExternalModel() = PersonContact( mobile = mobile, ) -fun NetworkPersonContact.asEntities(): PersonContactEntities { +fun NetworkPersonContact.asEntities() = organization?.let { val organizationEntity = IncidentOrganizationEntity( - id = organization!!.id, - name = organization!!.name, + id = it.id, + name = it.name, primaryLocation = null, secondaryLocation = null, ) val personContact = asEntity() - val personToOrganization = PersonOrganizationCrossRef(id, organization!!.id) - return PersonContactEntities( + val personToOrganization = PersonOrganizationCrossRef(id, it.id) + PersonContactEntities( organization = organizationEntity, - organizationAffiliates = organization!!.affiliates, + organizationAffiliates = it.affiliates, personContact = personContact, personToOrganization = personToOrganization, ) 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 29e37d316..7b407fcac 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 @@ -120,7 +120,7 @@ class OfflineFirstCaseHistoryRepository @Inject constructor( private suspend fun queryUpdateUsers(userIds: Collection) { try { val networkUsers = networkDataSource.getUsers(userIds) - val entities = networkUsers.map(NetworkPersonContact::asEntities) + val entities = networkUsers.mapNotNull(NetworkPersonContact::asEntities) val organizations = entities.map(PersonContactEntities::organization) val affiliates = entities.map(PersonContactEntities::organizationAffiliates) From 540cf34abea86772b832ad88d5e82d4be6307084 Mon Sep 17 00:00:00 2001 From: hue Date: Sun, 10 Sep 2023 09:46:26 -0400 Subject: [PATCH 03/10] Refresh organization when viewing Cases --- .../caseeditor/ExistingCaseViewModel.kt | 5 +++++ .../caseeditor/NetworkRefreshClient.kt | 21 +++++++++++++++++++ .../sync/workers/SyncWorksitesWorker.kt | 2 +- 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt index 14c6d981a..a8c9181cb 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt @@ -85,6 +85,7 @@ class ExistingCaseViewModel @Inject constructor( accountDataRepository: AccountDataRepository, private val incidentsRepository: IncidentsRepository, organizationsRepository: OrganizationsRepository, + organizationRefresher: OrganizationRefresher, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, locationProvider: LocationProvider, @@ -276,6 +277,10 @@ class ExistingCaseViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { accountDataRefresher.updateMyOrganization(false) } + + viewModelScope.launch(ioDispatcher) { + organizationRefresher.pullOrganization(incidentIdArg) + } } val isLoading = dataLoader.isLoading diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/NetworkRefreshClient.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/NetworkRefreshClient.kt index 5a5f1a75f..1e63ce03e 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/NetworkRefreshClient.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/NetworkRefreshClient.kt @@ -1,6 +1,7 @@ package com.crisiscleanup.feature.caseeditor import com.crisiscleanup.core.common.NetworkMonitor +import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.model.data.EmptyIncident @@ -56,3 +57,23 @@ class LanguageRefresher @Inject constructor( } } } + +@Singleton +class OrganizationRefresher @Inject constructor( + private val accountDataRefresher: AccountDataRefresher, +) { + private var incidentIdPull = EmptyIncident.id + private var pullTime = Instant.fromEpochSeconds(0) + + suspend fun pullOrganization(incidentId: Long) { + if (incidentIdPull == incidentId && + Clock.System.now() - pullTime < 1.hours + ) { + return + } + incidentIdPull = EmptyIncident.id + pullTime = Clock.System.now() + + accountDataRefresher.updateMyOrganization(true) + } +} diff --git a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt index 08025f290..c6a3c6638 100644 --- a/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt +++ b/sync/work/src/main/java/com/crisiscleanup/sync/workers/SyncWorksitesWorker.kt @@ -80,4 +80,4 @@ class SyncWorksitesWorker @AssistedInject constructor( .build() } } -} \ No newline at end of file +} From 6ca2a35fd6370babde24f08849318e754c56d1aa Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Sep 2023 18:53:52 -0400 Subject: [PATCH 04/10] Render map markers in the correct color and state and jump to case on map --- .../navigation/CrisisCleanupNavHost.kt | 2 +- .../src/main/java/CommonCaseConstants.kt | 5 ++ .../core/data/WorksiteInteractor.kt | 17 ++++ .../data/model/ExistingWorksiteIdentifier.kt | 18 ++++ .../core/designsystem/theme/Color.kt | 2 + .../core/mapmarker/MapCaseDotProvider.kt | 5 +- .../core/mapmarker/MapCaseIconProvider.kt | 2 + .../core/mapmarker/MarkerColor.kt | 11 ++- .../core/mapmarker/MarkerStatus.kt | 28 +++++- .../core/mapmarker/WorkTypeIconProvider.kt | 8 +- .../core/model/data/CaseStatus.kt | 3 +- .../core/model/data/CasesFilter.kt | 2 - .../caseeditor/CaseAddFlagViewModel.kt | 2 + .../feature/caseeditor/CaseEditorViewModel.kt | 2 + .../caseeditor/CasesWorksiteInteractor.kt | 67 ++++++++++++++ .../caseeditor/EditCaseLocationViewModel.kt | 1 + .../caseeditor/EditCasePropertyViewModel.kt | 1 + .../caseeditor/ExistingCaseViewModel.kt | 72 ++++++++++++++- .../caseeditor/ExistingWorksiteSelector.kt | 18 +--- .../caseeditor/model/FormFieldsInputData.kt | 5 +- .../navigation/CaseEditorNavigation.kt | 12 ++- .../feature/caseeditor/ui/CaseEditorScreen.kt | 8 +- .../feature/caseeditor/ui/DynamicFormInput.kt | 4 + .../caseeditor/ui/EditExistingCaseScreen.kt | 42 ++++++++- .../caseeditor/ui/ExistingWorkTypeViews.kt | 14 ++- .../feature/caseeditor/ui/FormDataScreen.kt | 2 + .../caseeditor/ui/FullAddressSearchScreen.kt | 2 +- .../feature/caseeditor/ui/LocalCaseEditor.kt | 11 +++ .../caseeditor/ui/MoveLocationOnMapScreen.kt | 8 +- .../caseeditor/ui/PropertyLocationViews.kt | 2 +- .../caseeditor/ui/WorkTypeStatusOptions.kt | 10 ++- .../caseeditor/ui/addflag/AddFlagScreen.kt | 4 +- .../res/drawable/ic_jump_to_case_on_map.xml | 18 ++++ .../feature/cases/CasesViewModel.kt | 90 ++++--------------- .../cases/map/CasesMapMarkerManager.kt | 73 +++++++++++++++ .../feature/cases/model/WorksiteMapMark.kt | 2 + .../feature/cases/ui/CasesScreenTableView.kt | 4 +- 37 files changed, 451 insertions(+), 126 deletions(-) create mode 100644 core/commoncase/src/main/java/CommonCaseConstants.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/WorksiteInteractor.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/model/ExistingWorksiteIdentifier.kt create mode 100644 feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CasesWorksiteInteractor.kt create mode 100644 feature/caseeditor/src/main/res/drawable/ic_jump_to_case_on_map.xml diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 0d9a01209..97bcdf4b8 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -7,9 +7,9 @@ import androidx.compose.ui.Modifier import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import com.crisiscleanup.core.appnav.RouteConstant.casesGraphRoutePattern +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.EmptyWorksite -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.navigation.caseAddFlagScreen import com.crisiscleanup.feature.caseeditor.navigation.caseEditMoveLocationOnMapScreen import com.crisiscleanup.feature.caseeditor.navigation.caseEditSearchAddressScreen diff --git a/core/commoncase/src/main/java/CommonCaseConstants.kt b/core/commoncase/src/main/java/CommonCaseConstants.kt new file mode 100644 index 000000000..e279609db --- /dev/null +++ b/core/commoncase/src/main/java/CommonCaseConstants.kt @@ -0,0 +1,5 @@ +package com.crisiscleanup.core.commoncase + +import java.text.DecimalFormat + +val oneDecimalFormat = DecimalFormat("#.#") diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/WorksiteInteractor.kt b/core/data/src/main/java/com/crisiscleanup/core/data/WorksiteInteractor.kt new file mode 100644 index 000000000..c9dd9a80b --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/WorksiteInteractor.kt @@ -0,0 +1,17 @@ +package com.crisiscleanup.core.data + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +interface WorksiteInteractor { + fun onSelectCase( + incidentId: Long, + worksiteId: Long, + ) + + fun wasCaseSelected( + incidentId: Long, + worksiteId: Long, + reference: Instant = Clock.System.now(), + ): Boolean +} diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/ExistingWorksiteIdentifier.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/ExistingWorksiteIdentifier.kt new file mode 100644 index 000000000..8368e35af --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/ExistingWorksiteIdentifier.kt @@ -0,0 +1,18 @@ +package com.crisiscleanup.core.data.model + +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.EmptyWorksite + +data class ExistingWorksiteIdentifier( + val incidentId: Long, + // This is the local (database) ID not network ID + val worksiteId: Long, +) { + val isDefined = incidentId != EmptyIncident.id && + worksiteId != EmptyWorksite.id +} + +val ExistingWorksiteIdentifierNone = ExistingWorksiteIdentifier( + EmptyIncident.id, + EmptyWorksite.id, +) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Color.kt index b89d3d4cb..b923cd7b8 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/theme/Color.kt @@ -89,6 +89,8 @@ val statusDoneByOthersNhwDiColor = Color(statusDoneByOthersNhwColorCode) val statusOutOfScopeRejectedColor = Color(statusOutOfScopeRejectedColorCode) val statusUnresponsiveColor = Color(statusUnresponsiveColorCode) +val visitedCaseMarkerColorCode = 0xFF681da8 + val avatarAttentionColor = primaryRedColor val avatarStandardColor = statusCompletedColor diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseDotProvider.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseDotProvider.kt index d9652270a..fbdd63e80 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseDotProvider.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseDotProvider.kt @@ -59,7 +59,8 @@ class InMemoryDotProvider @Inject constructor( cacheKey.statusClaim, cacheKey.isDuplicate, cacheKey.isFilteredOut, - true, + isVisited = false, + isDot = true, ) val bitmap = drawDot(colors, dotDrawProperties) val bitmapDescriptor = BitmapDescriptorFactory.fromBitmap(bitmap) @@ -82,6 +83,7 @@ class InMemoryDotProvider @Inject constructor( hasMultipleWorkTypes: Boolean, isDuplicate: Boolean, isFilteredOut: Boolean, + isVisited: Boolean, ): BitmapDescriptor? { val cacheKey = DotCacheKey(statusClaim, isDuplicate, isFilteredOut) synchronized(cache) { @@ -99,6 +101,7 @@ class InMemoryDotProvider @Inject constructor( hasMultipleWorkTypes: Boolean, isDuplicate: Boolean, isFilteredOut: Boolean, + isVisited: Boolean, ): Bitmap? { val cacheKey = DotCacheKey(statusClaim, isDuplicate, isFilteredOut) synchronized(cache) { diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseIconProvider.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseIconProvider.kt index 13728bd0c..3ce6672a6 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseIconProvider.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MapCaseIconProvider.kt @@ -20,6 +20,7 @@ interface MapCaseIconProvider { hasMultipleWorkTypes: Boolean, isDuplicate: Boolean = false, isFilteredOut: Boolean = false, + isVisited: Boolean = false, ): BitmapDescriptor? fun getIconBitmap( @@ -28,5 +29,6 @@ interface MapCaseIconProvider { hasMultipleWorkTypes: Boolean, isDuplicate: Boolean = false, isFilteredOut: Boolean = false, + isVisited: Boolean = false, ): Bitmap? } diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt index 2cd78009f..87f2d534c 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerColor.kt @@ -12,9 +12,10 @@ import com.crisiscleanup.core.designsystem.theme.statusOutOfScopeRejectedColorCo import com.crisiscleanup.core.designsystem.theme.statusPartiallyCompletedColorCode import com.crisiscleanup.core.designsystem.theme.statusUnclaimedColorCode import com.crisiscleanup.core.designsystem.theme.statusUnknownColorCode +import com.crisiscleanup.core.designsystem.theme.visitedCaseMarkerColorCode import com.crisiscleanup.core.model.data.CaseStatus.ClaimedNotStarted import com.crisiscleanup.core.model.data.CaseStatus.Completed -import com.crisiscleanup.core.model.data.CaseStatus.DoneByOthersNhwPc +import com.crisiscleanup.core.model.data.CaseStatus.DoneByOthersNhw import com.crisiscleanup.core.model.data.CaseStatus.InProgress import com.crisiscleanup.core.model.data.CaseStatus.Incomplete import com.crisiscleanup.core.model.data.CaseStatus.NeedsFollowUp @@ -43,7 +44,7 @@ private val statusMapMarkerColors = mapOf( PartiallyCompleted to MapMarkerColor(statusPartiallyCompletedColorCode), NeedsFollowUp to MapMarkerColor(statusNeedsFollowUpColorCode), Completed to MapMarkerColor(statusCompletedColorCode), - DoneByOthersNhwPc to MapMarkerColor(statusDoneByOthersNhwColorCode), + DoneByOthersNhw to MapMarkerColor(statusDoneByOthersNhwColorCode), // Unresponsive OutOfScopeDu to MapMarkerColor(statusOutOfScopeRejectedColorCode), Incomplete to MapMarkerColor(statusDoneByOthersNhwColorCode), @@ -75,6 +76,7 @@ internal fun getMapMarkerColors( statusClaim: WorkTypeStatusClaim, isDuplicate: Boolean, isFilteredOut: Boolean, + isVisited: Boolean, isDot: Boolean, ): MapMarkerColor { var colors = statusClaimMapMarkerColors[statusClaim] @@ -98,6 +100,11 @@ internal fun getMapMarkerColors( stroke = it.stroke.copy(alpha = strokeAlpha), ) } + } else if (isVisited) { + colors = MapMarkerColor( + colors.fillLong, + strokeLong = visitedCaseMarkerColorCode, + ) } return colors diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerStatus.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerStatus.kt index d4476c38c..f0aec0f24 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerStatus.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/MarkerStatus.kt @@ -1,9 +1,27 @@ package com.crisiscleanup.core.mapmarker import com.crisiscleanup.core.model.data.CaseStatus -import com.crisiscleanup.core.model.data.CaseStatus.* +import com.crisiscleanup.core.model.data.CaseStatus.ClaimedNotStarted +import com.crisiscleanup.core.model.data.CaseStatus.Completed +import com.crisiscleanup.core.model.data.CaseStatus.DoneByOthersNhw +import com.crisiscleanup.core.model.data.CaseStatus.InProgress +import com.crisiscleanup.core.model.data.CaseStatus.Incomplete +import com.crisiscleanup.core.model.data.CaseStatus.NeedsFollowUp +import com.crisiscleanup.core.model.data.CaseStatus.OutOfScopeDu +import com.crisiscleanup.core.model.data.CaseStatus.PartiallyCompleted +import com.crisiscleanup.core.model.data.CaseStatus.Unclaimed import com.crisiscleanup.core.model.data.WorkTypeStatus -import com.crisiscleanup.core.model.data.WorkTypeStatus.* +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedCompleted +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedDoneByOthers +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedDuplicate +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedIncomplete +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedNoHelpWanted +import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedOutOfScope +import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenAssigned +import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenNeedsFollowUp +import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenPartiallyCompleted +import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenUnassigned +import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenUnresponsive import com.crisiscleanup.core.model.data.WorkTypeStatusClaim internal val statusClaimToStatus = mapOf( @@ -17,7 +35,8 @@ internal val statusClaimToStatus = mapOf( WorkTypeStatusClaim(ClosedIncomplete, true) to Incomplete, WorkTypeStatusClaim(ClosedOutOfScope, true) to OutOfScopeDu, WorkTypeStatusClaim(ClosedDuplicate, true) to OutOfScopeDu, - WorkTypeStatusClaim(ClosedDoneByOthers, true) to DoneByOthersNhwPc, + WorkTypeStatusClaim(ClosedDoneByOthers, true) to DoneByOthersNhw, + WorkTypeStatusClaim(ClosedNoHelpWanted, true) to DoneByOthersNhw, WorkTypeStatusClaim(WorkTypeStatus.Unknown, false) to CaseStatus.Unknown, WorkTypeStatusClaim(OpenAssigned, false) to Unclaimed, WorkTypeStatusClaim(OpenUnassigned, false) to Unclaimed, @@ -28,5 +47,6 @@ internal val statusClaimToStatus = mapOf( WorkTypeStatusClaim(ClosedIncomplete, false) to Incomplete, WorkTypeStatusClaim(ClosedOutOfScope, false) to OutOfScopeDu, WorkTypeStatusClaim(ClosedDuplicate, false) to OutOfScopeDu, - WorkTypeStatusClaim(ClosedDoneByOthers, false) to DoneByOthersNhwPc, + WorkTypeStatusClaim(ClosedDoneByOthers, false) to DoneByOthersNhw, + WorkTypeStatusClaim(ClosedNoHelpWanted, false) to DoneByOthersNhw, ) diff --git a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/WorkTypeIconProvider.kt b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/WorkTypeIconProvider.kt index 39ae01a84..8aabf34c3 100644 --- a/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/WorkTypeIconProvider.kt +++ b/core/mapmarker/src/main/java/com/crisiscleanup/core/mapmarker/WorkTypeIconProvider.kt @@ -123,6 +123,7 @@ class WorkTypeIconProvider @Inject constructor( hasMultipleWorkTypes: Boolean, isDuplicate: Boolean, isFilteredOut: Boolean, + isVisited: Boolean, ): BitmapDescriptor { val cacheKey = WorkTypeIconCacheKey( statusClaim, @@ -132,6 +133,7 @@ class WorkTypeIconProvider @Inject constructor( isImportant = isImportant, isDuplicate = isDuplicate, isFilteredOut = isFilteredOut, + isVisited = isVisited, ) synchronized(cache) { cache.get(cacheKey)?.let { @@ -148,6 +150,7 @@ class WorkTypeIconProvider @Inject constructor( hasMultipleWorkTypes: Boolean, isDuplicate: Boolean, isFilteredOut: Boolean, + isVisited: Boolean, ): Bitmap? { val cacheKey = WorkTypeIconCacheKey( statusClaim, @@ -155,6 +158,7 @@ class WorkTypeIconProvider @Inject constructor( hasMultipleWorkTypes, isDuplicate, isFilteredOut, + isVisited, ) synchronized(cache) { bitmapCache.get(cacheKey)?.let { @@ -203,7 +207,8 @@ class WorkTypeIconProvider @Inject constructor( cacheKey.statusClaim, cacheKey.isDuplicate, cacheKey.isFilteredOut, - false, + cacheKey.isVisited, + isDot = false, ) val fillAlpha = if (colors.fill.alpha < 1) (colors.fill.alpha * 255).toInt() else 255 @@ -322,4 +327,5 @@ private data class WorkTypeIconCacheKey( val isImportant: Boolean = false, val isDuplicate: Boolean = false, val isFilteredOut: Boolean = false, + val isVisited: Boolean = false, ) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/CaseStatus.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/CaseStatus.kt index eb1e1332c..83e7d4fab 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/CaseStatus.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/CaseStatus.kt @@ -13,9 +13,8 @@ enum class CaseStatus { // TODO Review colors (and names) on web. There are marker colors and status colors... /** * Nhw = no help wanted - * Pc = partially completed */ - DoneByOthersNhwPc, + DoneByOthersNhw, /** * Du = Duplicate or unresponsive diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/CasesFilter.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/CasesFilter.kt index 993d1926c..8e5f88b3f 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/CasesFilter.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/CasesFilter.kt @@ -175,8 +175,6 @@ data class CasesFilter( val matchingFlags: Set by lazy { worksiteFlags.map(WorksiteFlagType::literal).toSet() } - - // TODO How to determine no work type? } private val DefaultCasesFilter = CasesFilter() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseAddFlagViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseAddFlagViewModel.kt index 82547f15c..7e13dd4df 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseAddFlagViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseAddFlagViewModel.kt @@ -14,6 +14,8 @@ import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.common.throttleLatest import com.crisiscleanup.core.commoncase.WorksiteProvider import com.crisiscleanup.core.data.IncidentSelectManager +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.AppDataManagementRepository import com.crisiscleanup.core.data.repository.IncidentsRepository diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt index 8045f0f92..825167f86 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt @@ -18,6 +18,8 @@ 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.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.LanguageTranslationsRepository diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CasesWorksiteInteractor.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CasesWorksiteInteractor.kt new file mode 100644 index 000000000..9d2d3f6e0 --- /dev/null +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CasesWorksiteInteractor.kt @@ -0,0 +1,67 @@ +package com.crisiscleanup.feature.caseeditor + +import com.crisiscleanup.core.common.di.ApplicationScope +import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorksiteInteractor +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.hours + +@Singleton +class CasesWorksiteInteractor @Inject constructor( + incidentSelector: IncidentSelector, + @ApplicationScope externalScope: CoroutineScope, +) : WorksiteInteractor { + private val selectedCases = mutableMapOf() + + private val recentlySelectedDuration = 1.hours + + init { + incidentSelector.incidentId + .onEach { + clearSelection() + } + .launchIn(externalScope) + } + + private fun clearSelection() { + selectedCases.clear() + } + + override fun onSelectCase(incidentId: Long, worksiteId: Long) { + val identifier = ExistingWorksiteIdentifier(incidentId, worksiteId) + selectedCases[identifier] = Clock.System.now() + } + + override fun wasCaseSelected( + incidentId: Long, + worksiteId: Long, + reference: Instant, + ): Boolean { + val identifier = ExistingWorksiteIdentifier(incidentId, worksiteId) + selectedCases[identifier]?.let { selectedTime -> + return reference - selectedTime < recentlySelectedDuration + } + return false + } +} + +@Module +@InstallIn(SingletonComponent::class) +interface WorksiteInteractorModule { + @Singleton + @Binds + fun bindsWorksiteInteractor( + worksiteInteractor: CasesWorksiteInteractor, + ): WorksiteInteractor +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt index a133de6c8..04789d7cb 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCaseLocationViewModel.kt @@ -18,6 +18,7 @@ import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.Default import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.commoncase.model.CaseSummaryResult +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.repository.SearchWorksitesRepository import com.crisiscleanup.core.mapmarker.DrawableResourceBitmapProvider import com.crisiscleanup.core.mapmarker.IncidentBoundsProvider diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCasePropertyViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCasePropertyViewModel.kt index 58b6b84a5..ef2e1c95a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCasePropertyViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/EditCasePropertyViewModel.kt @@ -4,6 +4,7 @@ import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.commoncase.model.CaseSummaryResult +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.data.repository.SearchWorksitesRepository import com.crisiscleanup.core.mapmarker.MapCaseIconProvider import com.crisiscleanup.core.model.data.AutoContactFrequency diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt index a8c9181cb..1f193ecdd 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt @@ -21,15 +21,19 @@ import com.crisiscleanup.core.common.PermissionManager import com.crisiscleanup.core.common.PermissionStatus import com.crisiscleanup.core.common.cameraPermissionGranted import com.crisiscleanup.core.common.combineTrimText +import com.crisiscleanup.core.common.haversineDistance +import com.crisiscleanup.core.common.kmToMiles 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.radians import com.crisiscleanup.core.common.relativeTime import com.crisiscleanup.core.common.sync.SyncPusher import com.crisiscleanup.core.commoncase.TransferWorkTypeProvider import com.crisiscleanup.core.commoncase.WorkTypeTransferType +import com.crisiscleanup.core.commoncase.oneDecimalFormat import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.IncidentsRepository @@ -52,6 +56,7 @@ import com.crisiscleanup.core.model.data.WorksiteNote import com.crisiscleanup.feature.caseeditor.model.CaseImage import com.crisiscleanup.feature.caseeditor.model.ImageCategory import com.crisiscleanup.feature.caseeditor.model.asCaseImage +import com.crisiscleanup.feature.caseeditor.model.coordinates import com.crisiscleanup.feature.caseeditor.navigation.ExistingCaseArgs import com.google.android.gms.maps.model.BitmapDescriptor import com.philjay.RRule @@ -86,6 +91,7 @@ class ExistingCaseViewModel @Inject constructor( private val incidentsRepository: IncidentsRepository, organizationsRepository: OrganizationsRepository, organizationRefresher: OrganizationRefresher, + worksiteInteractor: CasesWorksiteInteractor, incidentRefresher: IncidentRefresher, incidentBoundsProvider: IncidentBoundsProvider, locationProvider: LocationProvider, @@ -182,6 +188,32 @@ class ExistingCaseViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + val distanceAwayText = editableWorksite.map { worksite -> + locationProvider.getLocation()?.let { (latitude, longitude) -> + val worksiteLatRad = worksite.latitude.radians + val worksiteLngRad = worksite.longitude.radians + val latRad = latitude.radians + val lngRad = longitude.radians + val distanceAwayMi = haversineDistance( + latRad, + lngRad, + worksiteLatRad, + worksiteLngRad, + ).kmToMiles + val distanceAwayText = oneDecimalFormat.format(distanceAwayMi) + return@map "$distanceAwayText ${translator("caseView.miles_abbrv")}" + } + + "" + } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(), + ) + + val jumpToCaseOnMapOnBack = MutableStateFlow(false) + private val previousNoteCount = AtomicInteger(0) var addImageCategory by mutableStateOf(ImageCategory.Before) @@ -281,6 +313,8 @@ class ExistingCaseViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { organizationRefresher.pullOrganization(incidentIdArg) } + + worksiteInteractor.onSelectCase(incidentIdArg, worksiteIdArg) } val isLoading = dataLoader.isLoading @@ -621,6 +655,40 @@ class ExistingCaseViewModel @Inject constructor( } } + fun jumpToCaseOnMap() { + caseData.value?.let { + val coordinates = it.worksite.coordinates + editableWorksiteProvider.setEditedLocation(coordinates) + jumpToCaseOnMapOnBack.value = true + } + } + + private fun saveWorkTypeChange( + startingWorksite: Worksite, + changedWorksite: Worksite, + ) { + var updatedWorksite = changedWorksite + + var workTypes = changedWorksite.workTypes + if (workTypes.isNotEmpty()) { + workTypes = workTypes.sortedBy(WorkType::workTypeLiteral) + + var keyWorkType = workTypes.first() + changedWorksite.keyWorkType?.workTypeLiteral?.let { keyWorkTypeLiteral -> + workTypes.find { keyWorkTypeLiteral == it.workTypeLiteral } + ?.let { matchingWorkType -> + keyWorkType = matchingWorkType + } + } + + updatedWorksite = changedWorksite.copy( + keyWorkType = keyWorkType, + ) + } + + saveWorksiteChange(startingWorksite, updatedWorksite) + } + fun updateWorkType(workType: WorkType, isStatusChange: Boolean) { val startingWorksite = referenceWorksite val updatedWorkTypes = @@ -637,7 +705,7 @@ class ExistingCaseViewModel @Inject constructor( add(changed) } val changedWorksite = startingWorksite.copy(workTypes = updatedWorkTypes) - saveWorksiteChange(startingWorksite, changedWorksite) + saveWorkTypeChange(startingWorksite, changedWorksite) } fun requestWorkType(workType: WorkType) { @@ -681,7 +749,7 @@ class ExistingCaseViewModel @Inject constructor( } } val changedWorksite = startingWorksite.copy(workTypes = updatedWorkTypes) - saveWorksiteChange(startingWorksite, changedWorksite) + saveWorkTypeChange(startingWorksite, changedWorksite) } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingWorksiteSelector.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingWorksiteSelector.kt index 59be99d85..19b94cb21 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingWorksiteSelector.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingWorksiteSelector.kt @@ -1,26 +1,12 @@ package com.crisiscleanup.feature.caseeditor +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifierNone import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.WorksitesRepository -import com.crisiscleanup.core.model.data.EmptyIncident -import com.crisiscleanup.core.model.data.EmptyWorksite import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject -data class ExistingWorksiteIdentifier( - val incidentId: Long, - // This is the local (database) ID not network ID - val worksiteId: Long, -) { - val isDefined = incidentId != EmptyIncident.id && - worksiteId != EmptyWorksite.id -} - -val ExistingWorksiteIdentifierNone = ExistingWorksiteIdentifier( - EmptyIncident.id, - EmptyWorksite.id, -) - class ExistingWorksiteSelector @Inject constructor( private val worksiteProvider: EditableWorksiteProvider, private val incidentsRepository: IncidentsRepository, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/model/FormFieldsInputData.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/model/FormFieldsInputData.kt index 4ae6ca340..503c8cec9 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/model/FormFieldsInputData.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/model/FormFieldsInputData.kt @@ -16,7 +16,8 @@ open class FormFieldsInputData( val helpText: String = groupNode.formField.help, private val isWorkInputData: Boolean = false, ) : CaseDataWriter { - private val worksiteIn = worksite.copy() + private val worksiteIn = worksite + private val workTypeLookup = worksite.workTypes.associateBy(WorkType::workTypeLiteral) private val managedGroups = mutableSetOf() @@ -82,6 +83,8 @@ open class FormFieldsInputData( .filter { it.formField.isRequired } .map { it.formField } + fun isWorkTypeClaimed(workType: String) = workTypeLookup[workType]?.orgClaim != null + private fun resetUnmodifiedGroups(fieldData: Map): Map { if (groupFields.isEmpty()) { return fieldData 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 0adcf74bf..6b6a7f3cc 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 @@ -15,13 +15,14 @@ import com.crisiscleanup.core.appnav.RouteConstant.caseEditSearchAddressRoute import com.crisiscleanup.core.appnav.RouteConstant.caseEditorRoute import com.crisiscleanup.core.appnav.RouteConstant.caseHistoryRoute import com.crisiscleanup.core.appnav.RouteConstant.caseShareRoute +import com.crisiscleanup.core.appnav.RouteConstant.casesRoute import com.crisiscleanup.core.appnav.RouteConstant.viewCaseRoute import com.crisiscleanup.core.appnav.RouteConstant.viewCaseTransferWorkTypesRoute import com.crisiscleanup.core.appnav.ViewImageArgs import com.crisiscleanup.core.appnav.navigateToViewImage +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.EmptyWorksite -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.ui.CaseEditCaseHistoryRoute import com.crisiscleanup.feature.caseeditor.ui.CaseEditShareCaseRoute import com.crisiscleanup.feature.caseeditor.ui.CaseEditorRoute @@ -140,6 +141,7 @@ fun NavGraphBuilder.existingCaseScreen( }, ), ) { + val navBackToCases = remember(navController) { { navController.popToWork() } } val navToEditCase = remember(navController) { { ids: ExistingWorksiteIdentifier -> navController.navigateToCaseEditor( @@ -162,6 +164,7 @@ fun NavGraphBuilder.existingCaseScreen( val navToCaseHistory = remember(navController) { { navController.navigateToCaseHistory() } } EditExistingCaseRoute( onBack = onBackClick, + onBackToCases = navBackToCases, onFullEdit = navToEditCase, openTransferWorkType = navToTransferWorkType, openPhoto = navToViewImage, @@ -182,6 +185,13 @@ internal fun NavController.popRouteStartingWith(route: String) { } } +private fun NavController.popToWork() { + popBackStack() + while (currentBackStackEntry?.destination?.route?.let { it != casesRoute } == true) { + popBackStack() + } +} + fun NavController.rerouteToNewCase(incidentId: Long) { popRouteStartingWith(caseEditorRoute) navigateToCaseEditor(incidentId) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt index 006695e7a..5b85b730a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter @@ -49,7 +50,6 @@ import com.crisiscleanup.core.ui.scrollFlingListener import com.crisiscleanup.feature.caseeditor.CaseEditorUiState import com.crisiscleanup.feature.caseeditor.CaseEditorViewModel import com.crisiscleanup.feature.caseeditor.CasePropertyDataEditor -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.WorksiteSection import com.crisiscleanup.feature.caseeditor.model.FormFieldsInputData import com.crisiscleanup.core.common.R as commonR @@ -470,7 +470,11 @@ private fun LazyListScope.formDataSection( item( key = "section-$sectionIndex", ) { - FormDataItems(viewModel, inputData, LocalCaseEditor.current.isEditable) + FormDataItems( + viewModel, + inputData, + LocalCaseEditor.current.isEditable, + ) } } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/DynamicFormInput.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/DynamicFormInput.kt index 8d29e49be..2df56c32d 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/DynamicFormInput.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/DynamicFormInput.kt @@ -65,6 +65,7 @@ internal fun DynamicFormListItem( helpHint: String = "", showHelp: () -> Unit = {}, enabled: Boolean = true, + isWorkTypeClaimed: Boolean = false, updateValue: (FieldDynamicValue) -> Unit = {}, ) { val updateBoolean = remember(field) { @@ -174,6 +175,7 @@ internal fun DynamicFormListItem( helpHint, showHelp, enabled, + isWorkTypeClaimed = isWorkTypeClaimed, updateWorkTypeStatus = updateWorkTypeStatus, ) } @@ -223,6 +225,7 @@ private fun CheckboxItem( helpHint: String, showHelp: () -> Unit = {}, enabled: Boolean = true, + isWorkTypeClaimed: Boolean = false, updateWorkTypeStatus: (WorkTypeStatus) -> Unit = {}, ) { val isNewCase = LocalCaseEditor.current.isNewCase @@ -233,6 +236,7 @@ private fun CheckboxItem( @Composable { WorkTypeStatusDropdown( itemData.workTypeStatus, + isWorkTypeClaimed, updateWorkTypeStatus, true, ) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/EditExistingCaseScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/EditExistingCaseScreen.kt index fe23dd157..c95953e7c 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/EditExistingCaseScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/EditExistingCaseScreen.kt @@ -3,6 +3,7 @@ package com.crisiscleanup.feature.caseeditor.ui import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable @@ -20,6 +21,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope @@ -70,6 +72,7 @@ import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.filterNotBlankTrim import com.crisiscleanup.core.common.urlEncode import com.crisiscleanup.core.commoncase.model.addressQuery +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.CardSurface @@ -106,7 +109,6 @@ import com.crisiscleanup.core.model.data.WorksiteFlagType import com.crisiscleanup.core.model.data.WorksiteNote import com.crisiscleanup.core.ui.rememberIsKeyboardOpen import com.crisiscleanup.feature.caseeditor.ExistingCaseViewModel -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.R import com.crisiscleanup.feature.caseeditor.WorkTypeProfile import com.crisiscleanup.feature.caseeditor.model.CaseImage @@ -139,6 +141,7 @@ private val flagColors = mapOf( internal fun EditExistingCaseRoute( viewModel: ExistingCaseViewModel = hiltViewModel(), onBack: () -> Unit = {}, + onBackToCases: () -> Unit = {}, onFullEdit: (ExistingWorksiteIdentifier) -> Unit = {}, openTransferWorkType: () -> Unit = {}, openPhoto: (ViewImageArgs) -> Unit = { _ -> }, @@ -151,6 +154,11 @@ internal fun EditExistingCaseRoute( openTransferWorkType() } + val jumpToCaseOnMapOnBack by viewModel.jumpToCaseOnMapOnBack.collectAsStateWithLifecycle() + if (jumpToCaseOnMapOnBack) { + onBackToCases() + } + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() val isSaving by viewModel.isSaving.collectAsStateWithLifecycle() val isBusy = isLoading || isSaving @@ -568,6 +576,8 @@ internal fun EditExistingCaseInfoView( val releaseWorkType = remember(viewModel) { { workType: WorkType -> viewModel.releaseWorkType(workType) } } + val distanceAwayText by viewModel.distanceAwayText.collectAsStateWithLifecycle() + LazyColumn { item(key = "incident-info") { val caseData by viewModel.caseData.collectAsStateWithLifecycle() @@ -585,7 +595,13 @@ internal fun EditExistingCaseInfoView( } flagItems(worksite, removeFlag) - propertyInfoItems(worksite, mapMarkerIcon, copyToClipboard) + propertyInfoItems( + worksite, + mapMarkerIcon, + copyToClipboard, + distanceAwayText, + viewModel::jumpToCaseOnMap, + ) workItems( workTypeProfile, claimAll = claimAll, @@ -705,6 +721,8 @@ private fun LazyListScope.propertyInfoItems( worksite: Worksite, mapMarkerIcon: BitmapDescriptor? = null, copyToClipboard: (String?) -> Unit = {}, + distanceAwayText: String = "", + onJumpToCaseOnMap: () -> Unit = {}, ) { itemInfoSectionHeader(0, "caseForm.property_information") @@ -772,6 +790,26 @@ private fun LazyListScope.propertyInfoItems( locationQuery = geoQuery.ifBlank { locationQuery }, ) + Row( + Modifier + .testTag("editCasePropertyInfoJumpToCaseOnMap") + .clickable(onClick = onJumpToCaseOnMap) + .fillMaxWidth() + .padding(horizontal = edgeSpacing, vertical = edgeSpacingHalf), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = listItemSpacedBy, + ) { + Image( + modifier = Modifier.size(24.dp), + painter = painterResource(R.drawable.ic_jump_to_case_on_map), + contentDescription = LocalAppTranslator.current.translate("~~Center Case on map"), + ) + + if (distanceAwayText.isNotBlank()) { + Text(distanceAwayText, style = MaterialTheme.typography.bodyLarge) + } + } + PropertyInfoMapView( worksite.coordinates, // TODO Common dimensions diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ExistingWorkTypeViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ExistingWorkTypeViews.kt index 9aad679e3..c1c87ff65 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ExistingWorkTypeViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/ExistingWorkTypeViews.kt @@ -97,13 +97,17 @@ private fun WorkTypeSummaryView( Column { Text( name, - modifier.testTag("workTypeSummaryHeaderText").padding(top = edgeSpacing), + modifier + .testTag("workTypeSummaryHeaderText") + .padding(top = edgeSpacing), style = MaterialTheme.typography.bodyLarge, ) if (jobSummary.isNotBlank()) { Text( jobSummary, - modifier.testTag("workTypeSummarySubHeaderText").padding(top = edgeSpacingHalf), + modifier + .testTag("workTypeSummarySubHeaderText") + .padding(top = edgeSpacingHalf), style = MaterialTheme.typography.bodySmall, ) } @@ -112,7 +116,11 @@ private fun WorkTypeSummaryView( modifier = modifier.listItemVerticalPadding(), verticalAlignment = Alignment.CenterVertically, ) { - WorkTypeStatusDropdown(workType.status, updateWorkTypeStatus) + WorkTypeStatusDropdown( + workType.status, + workType.orgClaim != null, + updateWorkTypeStatus, + ) Spacer(Modifier.weight(1f)) val t = LocalAppTranslator.current diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt index 4709e53d4..5ca382bb1 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt @@ -51,6 +51,7 @@ private fun FormItems( } else { listItemModifier } + val isWorkTypeClaimed = inputData.isWorkTypeClaimed(state.field.selectToggleWorkType) DynamicFormListItem( state, label, @@ -60,6 +61,7 @@ private fun FormItems( helpHint, fieldShowHelp, isEditable, + isWorkTypeClaimed = isWorkTypeClaimed, ) { value: FieldDynamicValue -> state = state.copy( dynamicValue = value.dynamicValue, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt index 908c0f44e..eeaff93e3 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FullAddressSearchScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.text.input.KeyboardType import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.LinkifyHtmlText import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField @@ -21,7 +22,6 @@ import com.crisiscleanup.core.designsystem.theme.textMessagePadding import com.crisiscleanup.feature.caseeditor.CaseLocationDataEditor import com.crisiscleanup.feature.caseeditor.EditCaseBaseViewModel import com.crisiscleanup.feature.caseeditor.EditCaseLocationViewModel -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocalCaseEditor.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocalCaseEditor.kt index 279384199..c8056e851 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocalCaseEditor.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/LocalCaseEditor.kt @@ -18,6 +18,8 @@ import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedIncomplete import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedNoHelpWanted import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedOutOfScope import com.crisiscleanup.core.model.data.WorkTypeStatus.ClosedRejected +import com.crisiscleanup.core.model.data.WorkTypeStatus.NeedOverdue +import com.crisiscleanup.core.model.data.WorkTypeStatus.NeedUnfilled import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenAssigned import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenNeedsFollowUp import com.crisiscleanup.core.model.data.WorkTypeStatus.OpenPartiallyCompleted @@ -47,6 +49,15 @@ val statusOptionColors = mapOf( ClosedRejected to statusOutOfScopeRejectedColor, ) +val statusUnclaimedRed = setOf( + OpenUnassigned, + OpenAssigned, + OpenPartiallyCompleted, + OpenNeedsFollowUp, + NeedUnfilled, + NeedOverdue, +) + private val caseEditor = CaseEditor( isEditable = false, statusOptions = emptyList(), diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt index 3a441c192..0400f13cd 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/MoveLocationOnMapScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction @@ -32,7 +33,6 @@ import com.crisiscleanup.core.ui.MapOverlayMessage import com.crisiscleanup.feature.caseeditor.CaseLocationDataEditor import com.crisiscleanup.feature.caseeditor.EditCaseBaseViewModel import com.crisiscleanup.feature.caseeditor.EditCaseLocationViewModel -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.R import com.google.android.gms.maps.CameraUpdateFactory import com.google.android.gms.maps.Projection @@ -127,9 +127,11 @@ private fun BoxScope.MoveMapUnderLocation( ) { val onMapLoaded = remember(viewModel) { { editor.onMapLoaded() } } val onMapCameraChange = remember(viewModel) { - { position: CameraPosition, + { + position: CameraPosition, projection: Projection?, - isUserInteraction: Boolean, -> + isUserInteraction: Boolean, + -> editor.onMapCameraChange(position, projection, isUserInteraction) } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt index 032726c5e..ae7e355a3 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/PropertyLocationViews.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.data.model.ExistingWorksiteIdentifier import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.ExplainLocationPermissionDialog import com.crisiscleanup.core.designsystem.component.HelpRow @@ -26,7 +27,6 @@ import com.crisiscleanup.core.designsystem.theme.listItemHorizontalPadding import com.crisiscleanup.core.designsystem.theme.listItemPadding import com.crisiscleanup.feature.caseeditor.CaseLocationDataEditor import com.crisiscleanup.feature.caseeditor.EditCaseBaseViewModel -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier import com.crisiscleanup.feature.caseeditor.R import com.google.maps.android.compose.rememberCameraPositionState diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt index 3ec7f8497..cb7883c04 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt @@ -30,12 +30,14 @@ import com.crisiscleanup.core.designsystem.theme.listItemHeight import com.crisiscleanup.core.designsystem.theme.listItemPadding import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf import com.crisiscleanup.core.designsystem.theme.optionItemHeight +import com.crisiscleanup.core.designsystem.theme.statusUnclaimedColor import com.crisiscleanup.core.designsystem.theme.statusUnknownColor import com.crisiscleanup.core.model.data.WorkTypeStatus @Composable internal fun WorkTypeStatusDropdown( selectedStatus: WorkTypeStatus, + isClaimed: Boolean, onStatusChange: (WorkTypeStatus) -> Unit, applySpacing: Boolean = false, ) { @@ -67,6 +69,7 @@ internal fun WorkTypeStatusDropdown( restingModifier, true, enabled = enabled, + isUnclaimedColor = !isClaimed, ) if (showOptions && enabled) { @@ -119,6 +122,7 @@ private fun WorkTypeStatusOption( showOpenIcon: Boolean = false, enabled: Boolean = false, isSelected: Boolean = false, + isUnclaimedColor: Boolean = false, ) { Row( modifier = modifier, @@ -126,10 +130,14 @@ private fun WorkTypeStatusOption( horizontalArrangement = listItemSpacedByHalf, ) { val translator = LocalAppTranslator.current + var color = statusOptionColors[status] ?: statusUnknownColor + if (isUnclaimedColor && statusUnclaimedRed.contains(status)) { + color = statusUnclaimedColor + } Surface( Modifier.size(16.dp), shape = CircleShape, - color = statusOptionColors[status] ?: statusUnknownColor, + color = color, ) {} Text( translator(status.literal), diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/AddFlagScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/AddFlagScreen.kt index 490edab06..bf568c0c2 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/AddFlagScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/addflag/AddFlagScreen.kt @@ -37,6 +37,8 @@ import androidx.compose.ui.unit.toSize import androidx.compose.ui.window.PopupProperties import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +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.AnimatedBusyIndicator import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField @@ -55,8 +57,6 @@ import com.crisiscleanup.core.model.data.WorksiteFlagType import com.crisiscleanup.core.ui.rememberCloseKeyboard import com.crisiscleanup.core.ui.rememberIsKeyboardOpen import com.crisiscleanup.feature.caseeditor.CaseAddFlagViewModel -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifier -import com.crisiscleanup.feature.caseeditor.ExistingWorksiteIdentifierNone import com.crisiscleanup.feature.caseeditor.util.TwoActionBar @Composable diff --git a/feature/caseeditor/src/main/res/drawable/ic_jump_to_case_on_map.xml b/feature/caseeditor/src/main/res/drawable/ic_jump_to_case_on_map.xml new file mode 100644 index 000000000..ccae6901c --- /dev/null +++ b/feature/caseeditor/src/main/res/drawable/ic_jump_to_case_on_map.xml @@ -0,0 +1,18 @@ + + + + diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt index d5122b208..1489c4726 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt @@ -31,6 +31,7 @@ import com.crisiscleanup.core.commonassets.getDisasterIcon import com.crisiscleanup.core.commoncase.TransferWorkTypeProvider import com.crisiscleanup.core.commoncase.WorksiteProvider import com.crisiscleanup.core.data.IncidentSelector +import com.crisiscleanup.core.data.WorksiteInteractor import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.CasesFilterRepository import com.crisiscleanup.core.data.repository.IncidentsRepository @@ -51,7 +52,6 @@ import com.crisiscleanup.core.model.data.IncidentIdWorksiteCount import com.crisiscleanup.core.model.data.TableDataWorksite import com.crisiscleanup.core.model.data.TableWorksiteClaimAction import com.crisiscleanup.core.model.data.Worksite -import com.crisiscleanup.core.model.data.WorksiteMapMark import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.feature.cases.map.CasesMapBoundsManager import com.crisiscleanup.feature.cases.map.CasesMapMarkerManager @@ -89,10 +89,6 @@ import kotlinx.datetime.Instant import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicReference import javax.inject.Inject -import kotlin.math.PI -import kotlin.math.abs -import kotlin.math.cos -import kotlin.math.sin import kotlin.time.Duration.Companion.seconds import com.crisiscleanup.core.commonassets.R as commonAssetsR @@ -105,6 +101,7 @@ class CasesViewModel @Inject constructor( loadIncidentDataUseCase: LoadIncidentDataUseCase, dataPullReporter: IncidentDataPullReporter, private val mapCaseIconProvider: MapCaseIconProvider, + private val worksiteInteractor: WorksiteInteractor, private val mapTileRenderer: CasesOverviewMapTileRenderer, private val tileProvider: TileProvider, private val worksiteLocationEditor: WorksiteLocationEditor, @@ -467,20 +464,29 @@ class CasesViewModel @Inject constructor( return tileProvider } - private val zeroOffset = Pair(0f, 0f) private suspend fun generateWorksiteMarkers(wqs: WorksiteQueryState) = coroutineScope { val id = wqs.incidentId val sw = wqs.coordinateBounds.southWest val ne = wqs.coordinateBounds.northEast val marksQuery = mapMarkerManager.queryWorksitesInBounds(id, sw, ne) val marks = marksQuery.first - val markOffsets = denseMarkerOffsets(marks) + val markOffsets = mapMarkerManager.denseMarkerOffsets(marks, qsm.mapZoom.value) ensureActive() + val now = Clock.System.now() marks.mapIndexed { index, mark -> - val offset = if (index < markOffsets.size) markOffsets[index] else zeroOffset - mark.asWorksiteGoogleMapMark(mapCaseIconProvider, offset) + val offset = if (index < markOffsets.size) { + markOffsets[index] + } else { + mapMarkerManager.zeroOffset + } + val isSelected = + worksiteInteractor.wasCaseSelected(incidentId, mark.id, reference = now) + if (isSelected) { + logger.logDebug("Selected worksite ${mark.id} ${mark.workType}") + } + mark.asWorksiteGoogleMapMark(mapCaseIconProvider, isSelected, offset) } } @@ -681,72 +687,6 @@ class CasesViewModel @Inject constructor( } } - private val denseMarkCountThreshold = 15 - private val denseMarkZoomThreshold = 14 - private val denseDegreeThreshold = 0.0001 - private val denseScreenOffsetScale = 0.6f - private suspend fun denseMarkerOffsets(marks: List): List> = - coroutineScope { - if (marks.size > denseMarkCountThreshold || - qsm.mapZoom.value < denseMarkZoomThreshold - ) { - return@coroutineScope emptyList() - } - - ensureActive() - - val bucketIndices = IntArray(marks.size) { -1 } - val buckets = mutableListOf>() - for (i in 0 until marks.size - 1) { - val iMark = marks[i] - for (j in i + 1 until marks.size) { - val jMark = marks[j] - if (abs(iMark.latitude - jMark.latitude) < denseDegreeThreshold && - abs(iMark.longitude - jMark.longitude) < denseDegreeThreshold - ) { - val bucketI = bucketIndices[i] - if (bucketI >= 0) { - bucketIndices[j] = bucketI - buckets[bucketI].add(j) - } else { - val bucketJ = bucketIndices[j] - if (bucketJ >= 0) { - bucketIndices[i] = bucketJ - buckets[bucketJ].add(i) - } else { - val bucketIndex = buckets.size - bucketIndices[i] = bucketIndex - bucketIndices[j] = bucketIndex - buckets.add(mutableListOf(i, j)) - } - } - break - } - } - ensureActive() - } - - val markOffsets = marks.map { zeroOffset }.toMutableList() - if (buckets.isNotEmpty()) { - buckets.forEach { - val count = it.size - val offsetScale = denseScreenOffsetScale + (count - 5).coerceAtLeast(0) * 0.2f - if (count > 1) { - var offsetDir = (PI * 0.5).toFloat() - val deltaDirDegrees = (2 * PI / count).toFloat() - it.forEach { index -> - markOffsets[index] = Pair( - offsetScale * cos(offsetDir), - offsetScale * sin(offsetDir), - ) - offsetDir += deltaDirDegrees - } - } - } - } - markOffsets - } - private fun setSortBy(sortBy: WorksiteSortBy) { viewModelScope.launch(ioDispatcher) { if (sortBy != appPreferencesRepository.userPreferences.first().tableViewSortBy) { diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt index a810f961a..82fc0905d 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/map/CasesMapMarkerManager.kt @@ -12,6 +12,8 @@ import com.crisiscleanup.feature.cases.map.CoordinateUtil.lerpLongitude import com.google.android.gms.maps.model.LatLng import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlin.math.PI +import kotlin.math.abs import kotlin.math.cos import kotlin.math.pow import kotlin.math.sin @@ -138,6 +140,77 @@ internal class CasesMapMarkerManager( Pair(marks, q.fullCount) } + + val zeroOffset = Pair(0f, 0f) + + private val denseMarkCountThreshold = 15 + private val denseMarkZoomThreshold = 14 + private val denseDegreeThreshold = 0.0001 + private val denseScreenOffsetScale = 0.6f + suspend fun denseMarkerOffsets( + marks: List, + zoom: Float, + ): List> = + coroutineScope { + if (marks.size > denseMarkCountThreshold || + zoom < denseMarkZoomThreshold + ) { + return@coroutineScope emptyList() + } + + ensureActive() + + val bucketIndices = IntArray(marks.size) { -1 } + val buckets = mutableListOf>() + for (i in 0 until marks.size - 1) { + val iMark = marks[i] + for (j in i + 1 until marks.size) { + val jMark = marks[j] + if (abs(iMark.latitude - jMark.latitude) < denseDegreeThreshold && + abs(iMark.longitude - jMark.longitude) < denseDegreeThreshold + ) { + val bucketI = bucketIndices[i] + if (bucketI >= 0) { + bucketIndices[j] = bucketI + buckets[bucketI].add(j) + } else { + val bucketJ = bucketIndices[j] + if (bucketJ >= 0) { + bucketIndices[i] = bucketJ + buckets[bucketJ].add(i) + } else { + val bucketIndex = buckets.size + bucketIndices[i] = bucketIndex + bucketIndices[j] = bucketIndex + buckets.add(mutableListOf(i, j)) + } + } + break + } + } + ensureActive() + } + + val markOffsets = marks.map { zeroOffset }.toMutableList() + if (buckets.isNotEmpty()) { + buckets.forEach { + val count = it.size + val offsetScale = denseScreenOffsetScale + (count - 5).coerceAtLeast(0) * 0.2f + if (count > 1) { + var offsetDir = (PI * 0.5).toFloat() + val deltaDirDegrees = (2 * PI / count).toFloat() + it.forEach { index -> + markOffsets[index] = Pair( + offsetScale * cos(offsetDir), + offsetScale * sin(offsetDir), + ) + offsetDir += deltaDirDegrees + } + } + } + } + markOffsets + } } private data class BoundsQueryParams( diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/model/WorksiteMapMark.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/model/WorksiteMapMark.kt index c54d114a7..b7bffd275 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/model/WorksiteMapMark.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/model/WorksiteMapMark.kt @@ -18,6 +18,7 @@ data class WorksiteGoogleMapMark( fun WorksiteMapMark.asWorksiteGoogleMapMark( iconProvider: MapCaseIconProvider, + isVisited: Boolean, additionalScreenOffset: Pair, ): WorksiteGoogleMapMark { val latLng = LatLng(latitude, longitude) @@ -34,6 +35,7 @@ fun WorksiteMapMark.asWorksiteGoogleMapMark( hasMultipleWorkTypes = workTypeCount > 1, isDuplicate = isDuplicate, isFilteredOut = isFilteredOut, + isVisited = isVisited, ), mapIconOffset = Offset(0.5f + xOffset, 0.5f + yOffset), isFilteredOut = isFilteredOut, 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 bacf13e10..03f930d7c 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 @@ -51,6 +51,7 @@ 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 import com.crisiscleanup.core.commoncase.ui.IncidentDropdownSelect import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter @@ -80,7 +81,6 @@ import com.crisiscleanup.core.model.data.WorksiteSortBy import com.crisiscleanup.feature.cases.CasesViewModel import com.crisiscleanup.feature.cases.WorksiteDistance import kotlinx.coroutines.delay -import java.text.DecimalFormat @Composable internal fun BoxScope.CasesTableView( @@ -351,8 +351,6 @@ private fun TableViewSortSelect( } } -private val oneDecimalFormat = DecimalFormat("#.#") - @Composable private fun TableViewItem( worksiteDistance: WorksiteDistance, From 3f6877c15f1975acfcb61062fe22a40c74f8539b Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Sep 2023 20:15:04 -0400 Subject: [PATCH 05/10] Update key work type when claiming in create/edit Case --- app/build.gradle.kts | 2 +- .../feature/caseeditor/CaseEditorViewModel.kt | 12 ++++++++---- .../feature/caseeditor/util/WorkTypeUtil.kt | 9 ++++++--- .../crisiscleanup/feature/cases/CasesViewModel.kt | 4 ---- 4 files changed, 15 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acea0b4a4..399fb7250 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 150 + val buildVersion = 152 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.7.${buildVersion - 140}" diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt index 825167f86..f3f5472aa 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorViewModel.kt @@ -42,6 +42,7 @@ import com.crisiscleanup.feature.caseeditor.model.LocationInputData import com.crisiscleanup.feature.caseeditor.model.PropertyInputData import com.crisiscleanup.feature.caseeditor.model.coordinates import com.crisiscleanup.feature.caseeditor.navigation.CaseEditorArgs +import com.crisiscleanup.feature.caseeditor.util.matchKeyWorkType import com.crisiscleanup.feature.caseeditor.util.updateKeyWorkType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -529,6 +530,8 @@ class CaseEditorViewModel @Inject constructor( return@launch } + var keyWorkType = worksite.keyWorkType + val orgId = editorStateData.orgId var workTypes = worksite.workTypes if (claimUnclaimed) { workTypes = workTypes @@ -536,15 +539,15 @@ class CaseEditorViewModel @Inject constructor( if (it.orgClaim != null) { it } else { - it.copy(orgClaim = editorStateData.orgId) + it.copy(orgClaim = orgId) } } + keyWorkType = workTypes.matchKeyWorkType(initialWorksite) } val updatedIncidentId = if (isIncidentChange) saveIncidentId else worksite.incidentId - val updatedReportedBy = - if (worksite.isNew) editorStateData.orgId else worksite.reportedBy + val updatedReportedBy = if (worksite.isNew) orgId else worksite.reportedBy val clearWhat3Words = worksite.what3Words?.isNotBlank() == true && worksite.latitude != initialWorksite.latitude || worksite.longitude != initialWorksite.longitude @@ -552,6 +555,7 @@ class CaseEditorViewModel @Inject constructor( val updatedWorksite = worksite.copy( incidentId = updatedIncidentId, + keyWorkType = keyWorkType, workTypes = workTypes, reportedBy = updatedReportedBy, updatedAt = Clock.System.now(), @@ -562,7 +566,7 @@ class CaseEditorViewModel @Inject constructor( initialWorksite, updatedWorksite, updatedWorksite.keyWorkType!!, - editorStateData.orgId, + orgId, ) val worksiteId = worksiteIdArg!! diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/util/WorkTypeUtil.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/util/WorkTypeUtil.kt index 0aadffff8..36ed8ddea 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/util/WorkTypeUtil.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/util/WorkTypeUtil.kt @@ -87,7 +87,10 @@ fun Worksite.updateWorkTypeStatuses( // TODO Test coverage fun Worksite.updateKeyWorkType(reference: Worksite) = copy( - keyWorkType = reference.keyWorkType?.workType?.let { matchWorkType -> - workTypes.find { it.workType == matchWorkType } - } ?: workTypes.firstOrNull(), + keyWorkType = workTypes.matchKeyWorkType(reference), ) + +fun List.matchKeyWorkType(reference: Worksite) = + reference.keyWorkType?.workType?.let { matchWorkType -> + find { it.workType == matchWorkType } + } ?: firstOrNull() diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt index 1489c4726..c5804854d 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesViewModel.kt @@ -293,7 +293,6 @@ class CasesViewModel @Inject constructor( ) .mapLatest { (_, _, wqs) -> if (wqs.isTableView) { - logger.logDebug("Fetching table data") tableSortResultsMessage.value = "" fetchTableData(wqs) } else { @@ -483,9 +482,6 @@ class CasesViewModel @Inject constructor( } val isSelected = worksiteInteractor.wasCaseSelected(incidentId, mark.id, reference = now) - if (isSelected) { - logger.logDebug("Selected worksite ${mark.id} ${mark.workType}") - } mark.asWorksiteGoogleMapMark(mapCaseIconProvider, isSelected, offset) } } From f9d3ecdaf42e034aeeede45d8622cd841d6c3839 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 13 Sep 2023 20:17:26 -0400 Subject: [PATCH 06/10] Swap button positions in create/edit Case --- .../feature/caseeditor/ui/CaseEditorScreen.kt | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt index 5b85b730a..de13a513a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseEditorScreen.kt @@ -594,23 +594,23 @@ private fun SaveActionBar( ) BusyButton( Modifier - .testTag("caseEditClaimAndSaveBtn") - .weight(1.5f), - text = saveClaimText, + .testTag("caseEditSaveBtn") + .weight(1.1f), + text = saveText, enabled = enable, indicateBusy = isSaving, - onClick = onClaimAndSave, + onClick = onSave, isSharpCorners = isSharpCorners, style = style, ) BusyButton( Modifier - .testTag("caseEditSaveBtn") - .weight(1.1f), - text = saveText, + .testTag("caseEditClaimAndSaveBtn") + .weight(1.5f), + text = saveClaimText, enabled = enable, indicateBusy = isSaving, - onClick = onSave, + onClick = onClaimAndSave, isSharpCorners = isSharpCorners, style = style, ) From b66c84b54c9354ebca6a77aba80df237b8e66ef2 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 14 Sep 2023 10:55:56 -0400 Subject: [PATCH 07/10] Improve spacing in Case history user info when phone or email address is long --- app/build.gradle.kts | 2 +- .../caseeditor/ui/CaseHistoryScreen.kt | 35 ++++++++++++++++--- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 399fb7250..5cf5837f6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 152 + val buildVersion = 153 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.7.${buildVersion - 140}" diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt index 4a964e4b6..3e67acf3a 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt @@ -18,6 +18,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -28,6 +29,7 @@ import com.crisiscleanup.core.designsystem.component.CardSurface import com.crisiscleanup.core.designsystem.component.LinkifyEmailText import com.crisiscleanup.core.designsystem.component.LinkifyPhoneText import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy @@ -114,21 +116,29 @@ private fun HistoryUser( // TODO Common dimensions .padding(16.dp), ) { + val isLongPhone = userInfo.userPhone.length > 20 + val isLongEmail = userInfo.userEmail.length > 20 Row(horizontalArrangement = listItemSpacedBy) { Text( userInfo.userName, - Modifier.weight(1f), + Modifier.weight(if (isLongPhone) 0.5f else 1.0f), style = LocalFontStyles.current.header4, ) - LinkifyPhoneText(userInfo.userPhone) + LinkifyPhoneText( + userInfo.userPhone, + modifier = if (isLongPhone) Modifier.weight(0.5f) else Modifier, + ) } Row(horizontalArrangement = listItemSpacedBy) { Text( userInfo.orgName, - Modifier.weight(1f), + Modifier.weight(if (isLongEmail) 0.5f else 1.0f), ) if (userInfo.userEmail.isNotBlank()) { - LinkifyEmailText(userInfo.userEmail) + LinkifyEmailText( + userInfo.userEmail, + modifier =if (isLongEmail) Modifier.weight(0.5f) else Modifier, + ) } } } @@ -170,3 +180,20 @@ private fun HistoryEvents( } } } + +@Preview +@Composable +private fun previewHistoryUser() { + CrisisCleanupTheme { + HistoryUser( + CaseHistoryUserEvents( + 0, + "very long user name that is pointless to pronounce", + "very long organization name the likely has an abbreviation", + "user phone 1234567890", + "endless-user-email-address@organization.org", + emptyList(), + ), + ) + } +} \ No newline at end of file From 5863b1ba274d2692207bfad73835288af1b5c6a1 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 14 Sep 2023 12:23:27 -0400 Subject: [PATCH 08/10] Use system language for translations where supported --- .../java/com/crisiscleanup/MainActivity.kt | 6 ++++ .../core/data/model/NetworkLanguage.kt | 2 +- .../LanguageTranslationsRepository.kt | 28 +++++++++++++++---- .../core/network/di/NetworkModule.kt | 1 - .../core/network/model/NetworkLanguage.kt | 2 +- 5 files changed, 30 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/MainActivity.kt b/app/src/main/java/com/crisiscleanup/MainActivity.kt index 41ce722cd..728933adb 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivity.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivity.kt @@ -34,6 +34,7 @@ import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.common.sync.SyncPuller import com.crisiscleanup.core.data.repository.AppMetricsRepository import com.crisiscleanup.core.data.repository.EndOfLifeRepository +import com.crisiscleanup.core.data.repository.LanguageTranslationsRepository import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.navigationContainerColor import com.crisiscleanup.core.model.data.DarkThemeConfig @@ -92,6 +93,9 @@ class MainActivity : ComponentActivity() { @Inject internal lateinit var appMetricsRepository: AppMetricsRepository + @Inject + internal lateinit var languageTranslationsRepository: LanguageTranslationsRepository + private val lifecycleObservers = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { @@ -178,6 +182,8 @@ class MainActivity : ComponentActivity() { endOfLifeRepository.saveEndOfLifeData() appMetricsRepository.saveAppSupportInfo() + languageTranslationsRepository.setLanguageFromSystem() + scheduleSyncWorksites(this) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkLanguage.kt b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkLanguage.kt index a1b1239f3..4d90d7226 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkLanguage.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/model/NetworkLanguage.kt @@ -17,6 +17,6 @@ fun NetworkLanguageDescription.asEntity() = LanguageTranslationEntity( fun NetworkLanguageTranslation.asEntity(syncedAt: Instant) = LanguageTranslationEntity( key = subtag, name = name, - translationsJson = Json.encodeToString(translations), + translationsJson = Json.encodeToString(translations.filter { it.value != null }), syncedAt = syncedAt, ) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt index c6fb021f0..ca561d14c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt @@ -1,5 +1,6 @@ package com.crisiscleanup.core.data.repository +import com.crisiscleanup.core.common.AndroidResourceProvider import com.crisiscleanup.core.common.KeyTranslator import com.crisiscleanup.core.common.di.ApplicationScope import com.crisiscleanup.core.common.log.AppLogger @@ -33,6 +34,7 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import kotlinx.datetime.Clock +import java.util.Locale import javax.inject.Inject import javax.inject.Singleton @@ -46,6 +48,8 @@ interface LanguageTranslationsRepository : KeyTranslator { suspend fun loadLanguages(force: Boolean = false) fun setLanguage(key: String = "") + + fun setLanguageFromSystem() } @Singleton @@ -55,6 +59,7 @@ class OfflineFirstLanguageTranslationsRepository @Inject constructor( private val languageDao: LanguageDao, private val languageDaoPlus: LanguageDaoPlus, private val statusRepository: WorkTypeStatusRepository, + private val resourceProvider: AndroidResourceProvider, @Logger(CrisisCleanupLoggers.Language) private val logger: AppLogger, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @ApplicationScope private val coroutineScope: CoroutineScope, @@ -143,6 +148,8 @@ class OfflineFirstLanguageTranslationsRepository @Inject constructor( } else { pullUpdatedTranslations() } + + setLanguageFromSystem() } catch (e: Exception) { logger.logException(e) } finally { @@ -150,6 +157,14 @@ class OfflineFirstLanguageTranslationsRepository @Inject constructor( } } + override fun setLanguageFromSystem() { + val locales = resourceProvider.resources.configuration.locales + if (!locales.isEmpty) { + val systemLocale = Locale.getDefault().toLanguageTag() + setLanguage(systemLocale) + } + } + private suspend fun pullUpdatedTranslations() = pullUpdatedTranslations(currentLanguage.value.key) @@ -166,16 +181,17 @@ class OfflineFirstLanguageTranslationsRepository @Inject constructor( override fun setLanguage(key: String) { setLanguageJob?.cancel() setLanguageJob = coroutineScope.launch(ioDispatcher) { - try { - // TODO Set the language if local translations exist. - // Pull does not need to succeed in this case. - // Consider possible race condition if ordering changes. + val languages = supportedLanguages.first() + .map(Language::key) + .toSet() + val languageKey = if (languages.contains(key)) key else EnglishLanguage.key - pullUpdatedTranslations(key) + try { + pullUpdatedTranslations(languageKey) ensureActive() - appPreferences.setLanguageKey(key) + appPreferences.setLanguageKey(languageKey) } catch (e: Exception) { logger.logException(e) } finally { diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/di/NetworkModule.kt b/core/network/src/main/java/com/crisiscleanup/core/network/di/NetworkModule.kt index 44b14374b..850ef4134 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/di/NetworkModule.kt @@ -64,7 +64,6 @@ object NetworkModule { @OptIn(ExperimentalSerializationApi::class) @Provides - @Singleton fun providesNetworkJson() = Json { ignoreUnknownKeys = true explicitNulls = false diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt index bd5c073b2..f105b1a20 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkLanguage.kt @@ -27,5 +27,5 @@ data class NetworkLanguageTranslation( val subtag: String, @SerialName("name_t") val name: String, - val translations: Map, + val translations: Map, ) From 945d2439f13b502ce14c638419d2a2686e9ba9b3 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 14 Sep 2023 14:57:47 -0400 Subject: [PATCH 09/10] Update Case create/edit/view translations with appropriate prefixes --- .../feature/caseeditor/CaseEditorDataLoader.kt | 9 +++++++-- .../feature/caseeditor/ExistingCaseViewModel.kt | 9 +++++---- .../feature/caseeditor/ui/CaseHistoryScreen.kt | 4 ++-- .../feature/caseeditor/ui/FormDataScreen.kt | 6 +++++- .../feature/caseeditor/ui/WorkTypeStatusOptions.kt | 2 +- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt index a7bf99804..8baf69e53 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/CaseEditorDataLoader.kt @@ -267,11 +267,16 @@ internal class CaseEditorDataLoader( addAll( formFields.map { with(it.formField) { + val labelTranslateKey = "formLabels.$fieldKey" + var translatedLabel = translate(labelTranslateKey) + if (translatedLabel == labelTranslateKey) { + translatedLabel = translate(fieldKey) + } val isRequired = requiredGroups.contains(group) if (isRequired) { - "$label *" + "$translatedLabel *" } else { - label + translatedLabel } } }, diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt index 1f193ecdd..128715912 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ExistingCaseViewModel.kt @@ -426,15 +426,16 @@ class ExistingCaseViewModel @Inject constructor( val summaries = worksiteWorkTypes.map { workType -> val workTypeLiteral = workType.workTypeLiteral - var name = translate(workTypeLiteral) - if (name == workTypeLiteral) { - name = translate("workType.$workTypeLiteral") + val workTypeTranslateKey = "workType.$workTypeLiteral" + var name = translate(workTypeTranslateKey) + if (name == workTypeTranslateKey) { + name = translate(workTypeLiteral) } val workTypeLookup = stateData.incident.workTypeLookup val summaryJobTypes = worksite.formData ?.filter { formValue -> workTypeLookup[formValue.key] == workTypeLiteral } ?.filter { formValue -> formValue.value.isBooleanTrue } - ?.map { formValue -> translate(formValue.key) } + ?.map { formValue -> translate("formLabels.${formValue.key}") } ?.filter { jobName -> jobName != name } ?.filter(String::isNotBlank) ?: emptyList() diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt index 3e67acf3a..b08b90201 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/CaseHistoryScreen.kt @@ -137,7 +137,7 @@ private fun HistoryUser( if (userInfo.userEmail.isNotBlank()) { LinkifyEmailText( userInfo.userEmail, - modifier =if (isLongEmail) Modifier.weight(0.5f) else Modifier, + modifier = if (isLongEmail) Modifier.weight(0.5f) else Modifier, ) } } @@ -196,4 +196,4 @@ private fun previewHistoryUser() { ), ) } -} \ No newline at end of file +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt index 5ca382bb1..7c27e5601 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt @@ -40,7 +40,11 @@ private fun FormItems( } key(state.key) { - var label = state.field.label.ifBlank { translator(state.key) } + val labelTranslateKey = "formLabels.${state.key}" + var label = translator(labelTranslateKey) + if (label == labelTranslateKey) { + label = state.field.label + } if (state.field.isRequired) { label = "$label *" } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt index cb7883c04..8772692b4 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/WorkTypeStatusOptions.kt @@ -140,7 +140,7 @@ private fun WorkTypeStatusOption( color = color, ) {} Text( - translator(status.literal), + translator("status.${status.literal}"), style = MaterialTheme.typography.bodySmall, fontWeight = if (isSelected) FontWeight.Bold else null, ) From 5946b9ffbc24094ff8c48208a6ac2584bef96179 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 14 Sep 2023 16:13:13 -0400 Subject: [PATCH 10/10] Update more translations --- app/build.gradle.kts | 2 +- .../LanguageTranslationsRepository.kt | 7 ++----- .../feature/caseeditor/ui/FormDataScreen.kt | 11 ++++++++--- .../feature/caseeditor/ui/SectionHeader.kt | 17 ++++++++++++++--- 4 files changed, 25 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5cf5837f6..d4947a18b 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 153 + val buildVersion = 154 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.7.${buildVersion - 140}" diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt index ca561d14c..c2720d4ff 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LanguageTranslationsRepository.kt @@ -158,11 +158,8 @@ class OfflineFirstLanguageTranslationsRepository @Inject constructor( } override fun setLanguageFromSystem() { - val locales = resourceProvider.resources.configuration.locales - if (!locales.isEmpty) { - val systemLocale = Locale.getDefault().toLanguageTag() - setLanguage(systemLocale) - } + val systemLocale = Locale.getDefault().toLanguageTag() + setLanguage(systemLocale) } private suspend fun pullUpdatedTranslations() = diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt index 7c27e5601..b6578b50e 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/FormDataScreen.kt @@ -93,14 +93,19 @@ private fun HelpContent( viewModel: EditCaseBaseViewModel, content: @Composable ((FieldDynamicValue) -> Unit) -> Unit, ) { + val translator = LocalAppTranslator.current var helpTitle by remember { mutableStateOf("") } var helpText by remember { mutableStateOf("") } val showHelp = remember(viewModel) { { data: FieldDynamicValue -> - val text = data.field.help - if (text.isNotBlank()) { + if (data.field.help.isNotBlank()) { helpTitle = data.field.label - helpText = StringEscapeUtils.unescapeHtml4(text).toString() + + val helpTranslateKey = "formLabels.${data.field.help}" + val translated = translator(helpTranslateKey) + helpText = + if (translated == helpTranslateKey) translator(data.field.help) else translated + helpText = StringEscapeUtils.unescapeHtml4(helpText).toString() } } } diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/SectionHeader.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/SectionHeader.kt index 74a23947f..79b696bf5 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/SectionHeader.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ui/SectionHeader.kt @@ -17,6 +17,7 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.CollapsibleIcon import com.crisiscleanup.core.designsystem.component.HelpAction import com.crisiscleanup.core.designsystem.component.WithHelpDialog @@ -45,7 +46,9 @@ private fun CircleNumber( ) { Text( "$number", - Modifier.testTag("circleNumberText_$number").align(Alignment.Center), + Modifier + .testTag("circleNumberText_$number") + .align(Alignment.Center), style = style, textAlign = TextAlign.Center, ) @@ -86,7 +89,13 @@ internal fun SectionHeaderCollapsible( val iconVector = if (isCollapsed) CrisisCleanupIcons.ExpandLess else CrisisCleanupIcons.ExpandMore if (help.isNotBlank()) { - WithHelpDialog(viewModel, sectionTitle, help, true) { showHelp -> + val translator = LocalAppTranslator.current + val translateKey = "formLabels.$help" + var translated = translator(translateKey) + if (translated == translateKey) { + translated = translator(help) + } + WithHelpDialog(viewModel, sectionTitle, translated, true) { showHelp -> HelpAction(viewModel.helpHint, showHelp) } } @@ -117,7 +126,9 @@ internal fun SectionHeader( ) Text( sectionTitle, - Modifier.testTag("sectionHeaderTitle_${sIndex}_$sectionTitle").listRowItemStartPadding(), + Modifier + .testTag("sectionHeaderTitle_${sIndex}_$sectionTitle") + .listRowItemStartPadding(), style = textStyle, ) trailingContent?.let {