diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 253edd607..4718db32c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 234 + val buildVersion = 236 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 6c8e7cd23..b80f5f115 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -30,6 +30,7 @@ import com.crisiscleanup.core.data.repository.AccountUpdateRepository import com.crisiscleanup.core.data.repository.AppDataManagementRepository import com.crisiscleanup.core.data.repository.LocalAppMetricsRepository import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository +import com.crisiscleanup.core.data.repository.ShareLocationRepository import com.crisiscleanup.core.model.data.AccountData import com.crisiscleanup.core.model.data.AppMetricsData import com.crisiscleanup.core.model.data.AppOpenInstant @@ -80,6 +81,7 @@ class MainActivityViewModel @Inject constructor( externalEventBus: ExternalEventBus, private val accountEventBus: AccountEventBus, private val networkMonitor: NetworkMonitor, + private val shareLocationRepository: ShareLocationRepository, @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : ViewModel() { @@ -151,8 +153,6 @@ class MainActivityViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) - val translationCount = translator.translationCount - val buildEndOfLife: BuildEndOfLife? get() { if (appEnv.isEarlybird) { @@ -224,7 +224,10 @@ class MainActivityViewModel @Inject constructor( appPreferencesRepository.userPreferences.onEach { firebaseAnalytics.setAnalyticsCollectionEnabled(it.allowAllAnalytics) + + shareLocationWithOrganization() } + .flowOn(ioDispatcher) .launchIn(viewModelScope) syncPuller.appPullLanguage() @@ -271,6 +274,10 @@ class MainActivityViewModel @Inject constructor( } } } + + viewModelScope.launch(ioDispatcher) { + shareLocationWithOrganization() + } } fun onRejectTerms() { @@ -327,6 +334,10 @@ class MainActivityViewModel @Inject constructor( accountEventBus.onLogout() accountEventBus.clearAccountInactiveOrganization() } + + private suspend fun shareLocationWithOrganization() { + shareLocationRepository.shareLocation() + } } sealed interface MainActivityViewState { diff --git a/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt b/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt index 887c9560d..ec230d799 100644 --- a/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt +++ b/app/src/main/java/com/crisiscleanup/ui/AppNavigation.kt @@ -9,11 +9,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavDestination import androidx.navigation.NavDestination.Companion.hierarchy import com.crisiscleanup.core.designsystem.LocalAppTranslator @@ -67,8 +70,12 @@ private fun NavItems( @Composable () -> Unit, ) -> Unit, ) { + val t = LocalAppTranslator.current + val translationCount by t.translationCount.collectAsStateWithLifecycle() destinations.forEachIndexed { i, destination -> - val title = LocalAppTranslator.current(destination.titleTranslateKey) + val title = remember(translationCount) { + t(destination.titleTranslateKey) + } val selected = currentDestination.isTopLevelDestinationInHierarchy(destination) itemContent( selected, diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 5b4629865..776010380 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -91,7 +91,6 @@ fun CrisisCleanupApp( val isOffline by appState.isOffline.collectAsStateWithLifecycle() - val translationCount by viewModel.translationCount.collectAsStateWithLifecycle() val t = viewModel.translator LaunchedEffect(isOffline) { @@ -132,7 +131,6 @@ fun CrisisCleanupApp( appState, viewModel, authState, - translationCount, ) } } @@ -147,7 +145,6 @@ private fun BoxScope.LoadedContent( appState: CrisisCleanupAppState, viewModel: MainActivityViewModel, authState: AuthState, - translationCount: Int = 0, ) { val isAccountExpired = viewModel.isAccountExpired val hasAcceptedTerms = viewModel.hasAcceptedTerms @@ -229,7 +226,7 @@ private fun BoxScope.LoadedContent( isAccountExpired && !appState.hideLoginAlert ) { - ExpiredAccountAlert(snackbarHostState, translationCount) { + ExpiredAccountAlert(snackbarHostState) { openAuthentication = true } } @@ -451,12 +448,12 @@ private fun NavigableContent( @Composable private fun ExpiredAccountAlert( snackbarHostState: SnackbarHostState, - translationCount: Int, openAuthentication: () -> Unit, ) { - val translator = LocalAppTranslator.current - val message = translator("info.log_in_for_updates") - val loginText = translator("actions.login", authenticationR.string.login) + val t = LocalAppTranslator.current + val translationCount by t.translationCount.collectAsStateWithLifecycle() + val message = t("info.log_in_for_updates") + val loginText = t("actions.login", authenticationR.string.login) LaunchedEffect(translationCount) { val result = snackbarHostState.showSnackbar( message, diff --git a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt index 56984c697..d72e369fa 100644 --- a/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt +++ b/core/addresssearch/src/main/java/com/crisiscleanup/core/addresssearch/GooglePlaceAddressSearchRepository.kt @@ -152,6 +152,7 @@ class GooglePlaceAddressSearchRepository @Inject constructor( .build() val response = placesClient().fetchPlace(request).await() val addressTypeKeys = setOf( + "subpremise", "street_number", "route", "locality", @@ -178,8 +179,9 @@ class GooglePlaceAddressSearchRepository @Inject constructor( latitude = coordinates.latitude, longitude = coordinates.longitude, address = listOf( - addressComponentLookup["street_number"] ?: "", - addressComponentLookup["route"] ?: "", + addressComponentLookup["street_number"], + addressComponentLookup["route"], + addressComponentLookup["subpremise"], ).combineTrimText(), city = addressComponentLookup["locality"] ?: "", county = addressComponentLookup["administrative_area_level_2"] ?: "", diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/TextUtil.kt b/core/common/src/main/java/com/crisiscleanup/core/common/TextUtil.kt index c0bfbbc0d..05b71a900 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/TextUtil.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/TextUtil.kt @@ -4,10 +4,9 @@ import java.net.URLDecoder import java.net.URLEncoder import java.nio.charset.StandardCharsets -fun Collection.filterNotBlankTrim(): List { - val notBlank = filter { it?.isNotBlank() == true }.filterNotNull() - return notBlank.map(String::trim) -} +fun Collection.filterNotBlankTrim() = filter { it?.isNotBlank() == true } + .filterNotNull() + .map(String::trim) fun Collection.combineTrimText(separator: String = ", ") = filterNotBlankTrim().joinToString(separator) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt index 588757bea..864cfd057 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt @@ -32,6 +32,7 @@ import com.crisiscleanup.core.data.repository.CrisisCleanupListsRepository import com.crisiscleanup.core.data.repository.CrisisCleanupLocalImageRepository import com.crisiscleanup.core.data.repository.CrisisCleanupOrgVolunteerRepository import com.crisiscleanup.core.data.repository.CrisisCleanupRequestRedeployRepository +import com.crisiscleanup.core.data.repository.CrisisCleanupShareLocationRepository import com.crisiscleanup.core.data.repository.CrisisCleanupTeamsRepository import com.crisiscleanup.core.data.repository.CrisisCleanupWorkTypeStatusRepository import com.crisiscleanup.core.data.repository.CrisisCleanupWorksiteChangeRepository @@ -56,6 +57,7 @@ import com.crisiscleanup.core.data.repository.OrgVolunteerRepository import com.crisiscleanup.core.data.repository.OrganizationsRepository import com.crisiscleanup.core.data.repository.RequestRedeployRepository import com.crisiscleanup.core.data.repository.SearchWorksitesRepository +import com.crisiscleanup.core.data.repository.ShareLocationRepository import com.crisiscleanup.core.data.repository.TeamsRepository import com.crisiscleanup.core.data.repository.UsersRepository import com.crisiscleanup.core.data.repository.WorkTypeStatusRepository @@ -210,6 +212,11 @@ interface DataModule { @Binds fun bindsTeamsRepository(repository: CrisisCleanupTeamsRepository): TeamsRepository + + @Binds + fun bindsShareLocationRepository( + repository: CrisisCleanupShareLocationRepository, + ): ShareLocationRepository } @Module diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt index ff21b3efb..f93e2986d 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt @@ -64,4 +64,8 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setAnalytics(allowAll: Boolean) { preferencesDataSource.setAnalytics(allowAll) } + + override suspend fun setShareLocationWithOrg(share: Boolean) { + preferencesDataSource.setShareLocationWithOrg(share) + } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt index 41c4eb033..1504af798 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt @@ -18,6 +18,11 @@ interface IncidentsRepository { */ val incidents: Flow> + /** + * Stream of [Incident]s with active phone numbers + */ + val hotlineIncidents: Flow> + suspend fun getIncident(id: Long, loadFormFields: Boolean = false): Incident? suspend fun getIncidents(startAt: Instant): List suspend fun getIncidentsList(): List @@ -25,6 +30,7 @@ interface IncidentsRepository { fun streamIncident(id: Long): Flow suspend fun pullIncidents() + suspend fun pullHotlineIncidents() suspend fun pullIncident(id: Long) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt index c00a52163..8730ae591 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt @@ -32,4 +32,6 @@ interface LocalAppPreferencesRepository { suspend fun setTableViewSortBy(sortBy: WorksiteSortBy) suspend fun setAnalytics(allowAll: Boolean) + + suspend fun setShareLocationWithOrg(share: Boolean) } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 9320b0053..e1fc33d58 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -73,6 +73,12 @@ class OfflineFirstIncidentsRepository @Inject constructor( override val incidents: Flow> = incidentDao.streamIncidents().mapLatest { it.map(PopulatedIncident::asExternalModel) } + override val hotlineIncidents = incidents.mapLatest { + it.filter { incident -> + incident.activePhoneNumbers.isNotEmpty() + } + } + override suspend fun getIncident(id: Long, loadFormFields: Boolean) = withContext(ioDispatcher) { if (loadFormFields) { @@ -193,6 +199,22 @@ class OfflineFirstIncidentsRepository @Inject constructor( } } + override suspend fun pullHotlineIncidents() { + try { + val hotlineIncidents = networkDataSource + .getIncidentsNoAuth( + incidentsQueryFields, + after = Clock.System.now() - 120.days, + ) + .filter { it.activePhoneNumber?.isNotEmpty() == true } + if (hotlineIncidents.isNotEmpty()) { + saveIncidentsPrimaryData(hotlineIncidents) + } + } catch (e: Exception) { + logger.logDebug(e) + } + } + override suspend fun pullIncident(id: Long) { val networkIncident = networkDataSource.getIncident(id, fullIncidentQueryFields) networkIncident?.let { incident -> diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/ShareLocationRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ShareLocationRepository.kt new file mode 100644 index 000000000..427b73514 --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/ShareLocationRepository.kt @@ -0,0 +1,57 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.common.LocationProvider +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers +import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.network.CrisisCleanupWriteApi +import kotlinx.coroutines.flow.first +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import java.util.concurrent.atomic.AtomicReference +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Duration.Companion.minutes + +interface ShareLocationRepository { + suspend fun shareLocation() +} + +@Singleton +class CrisisCleanupShareLocationRepository @Inject +constructor( + private val accountDataRepository: AccountDataRepository, + private val appPreferencesRepository: LocalAppPreferencesRepository, + private val locationProvider: LocationProvider, + private val writeApiClient: CrisisCleanupWriteApi, + @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, +) : ShareLocationRepository { + + private var shareTimestamp = AtomicReference(Instant.fromEpochSeconds(0)) + private val shareInterval = 1.minutes + + override suspend fun shareLocation() { + val shareLocationWithOrg = + appPreferencesRepository.userPreferences.first().shareLocationWithOrg + val areTokensValid = accountDataRepository.accountData.first().areTokensValid + if (shareLocationWithOrg && + areTokensValid + ) { + locationProvider.getLocation()?.let { location -> + synchronized(shareTimestamp) { + val now = Clock.System.now() + if (shareTimestamp.get() + shareInterval > now) { + return + } + shareTimestamp.set(now) + } + + try { + writeApiClient.shareLocation(location.first, location.second) + } catch (e: Exception) { + logger.logException(e) + } + } + } + } +} \ No newline at end of file diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index fb5124350..2842d3b58 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -34,4 +34,6 @@ message UserPreferences { bool hide_getting_started_video = 10; bool is_menu_tutorial_done = 11; + + bool share_location_with_org = 12; } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index 97b6d6d9a..39d32d81a 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -52,6 +52,8 @@ class LocalAppPreferencesDataSource @Inject constructor( hideGettingStartedVideo = it.hideGettingStartedVideo, isMenuTutorialDone = it.isMenuTutorialDone, + + shareLocationWithOrg = it.shareLocationWithOrg, ) } @@ -157,4 +159,11 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { isMenuTutorialDone = isDone } } } + + + suspend fun setShareLocationWithOrg(share: Boolean) { + userPreferences.updateData { + it.copy { shareLocationWithOrg = share } + } + } } diff --git a/core/designsystem/build.gradle.kts b/core/designsystem/build.gradle.kts index cdc2e993f..8b217740d 100644 --- a/core/designsystem/build.gradle.kts +++ b/core/designsystem/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { implementation(projects.core.common) implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtimeCompose) api(libs.androidx.compose.foundation) api(libs.androidx.compose.foundation.layout) api(libs.androidx.compose.material.iconsExtended) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt new file mode 100644 index 000000000..9cb51fffe --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/HotlineView.kt @@ -0,0 +1,68 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.crisiscleanup.core.common.combineTrimText +import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy + +@Composable +fun HotlineHeaderView( + isExpanded: Boolean, + toggleExpandHotline: () -> Unit, +) { + val translator = LocalAppTranslator.current + val translationCount by translator.translationCount.collectAsStateWithLifecycle() + val hotlineText = remember(translationCount) { + translator("disasters.hotline") + } + Row( + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + .clickable(onClick = toggleExpandHotline) + .then(listItemModifier), + horizontalArrangement = listItemSpacedBy, + ) { + Text( + hotlineText, + Modifier.weight(1f), + style = LocalFontStyles.current.header2, + ) + CollapsibleIcon(!isExpanded, hotlineText) + } +} + +@Composable +fun HotlineIncidentView( + name: String, + activePhoneNumbers: List, + linkifyNumbers: Boolean = false, +) { + val phoneNumbers = activePhoneNumbers.combineTrimText(", ") + val text = "$name: $phoneNumbers" + val modifier = Modifier.background(MaterialTheme.colorScheme.primaryContainer) + .then(listItemModifier) + val style = LocalFontStyles.current.header4 + if (linkifyNumbers) { + LinkifyPhoneText( + text, + modifier, + com.crisiscleanup.core.designsystem.R.style.link_text_style_black, + ) + } else { + Text( + text, + modifier, + style = style, + ) + } +} \ No newline at end of file diff --git a/core/designsystem/src/main/res/values/styles.xml b/core/designsystem/src/main/res/values/styles.xml index 85c1e9f77..53e93c95b 100644 --- a/core/designsystem/src/main/res/values/styles.xml +++ b/core/designsystem/src/main/res/values/styles.xml @@ -9,4 +9,14 @@ @color/primary_blue 1.2 + + \ No newline at end of file diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index ad490b587..4223d9a96 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -20,4 +20,6 @@ data class UserData( val hideGettingStartedVideo: Boolean, val isMenuTutorialDone: Boolean, + + val shareLocationWithOrg: Boolean, ) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt index ae02c758c..0fac7ae52 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupNetworkDataSource.kt @@ -40,6 +40,13 @@ interface CrisisCleanupNetworkDataSource { after: Instant? = null, ): List + suspend fun getIncidentsNoAuth( + fields: List, + limit: Int = 250, + ordering: String = "-start_at", + after: Instant? = null, + ): List + suspend fun getIncidentsList( fields: List = listOf("id", "name", "short_name", "incident_type"), limit: Int = 250, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupWriteApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupWriteApi.kt index d910eb689..97434625b 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupWriteApi.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupWriteApi.kt @@ -76,4 +76,6 @@ interface CrisisCleanupWriteApi { ) suspend fun requestRedeploy(organizationId: Long, incidentId: Long): Boolean + + suspend fun shareLocation(latitude: Double, longitude: Double) } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkOrganization.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkOrganization.kt index 8f5111a0d..a6d089d71 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkOrganization.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkOrganization.kt @@ -16,7 +16,7 @@ data class NetworkIncidentOrganization( val name: String, val affiliates: Collection, @SerialName("is_active") - val isActive: Boolean, + val isActive: Boolean? = false, @SerialName("primary_location") val primaryLocation: Long?, @SerialName("secondary_location") diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPointLocation.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPointLocation.kt new file mode 100644 index 000000000..585213e1c --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPointLocation.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkPointLocation( + val point: NetworkLocationCoordinates, + val type: String = "point", +) + +@Serializable +data class NetworkLocationCoordinates( + val coordinates: List, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index 052baaeed..ad3b2e62a 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt @@ -77,6 +77,22 @@ private interface DataSourceApi { after: Instant?, ): NetworkIncidentsResult + @GET("incidents") + suspend fun getIncidentsNoAuth( + @Query("fields") + fields: String, + @Query("limit") + limit: Int, + @Query("sort") + ordering: String, + @Query("start_at__gt") + after: Instant?, + // Differentiates this endpoint call from getIncidents above + // when determining header keys (locally) + @Query("_ignore") + callerTag: String = "_no-auth", + ): NetworkIncidentsResult + @GET("incidents_list") suspend fun getIncidentsList( @Query("fields") @@ -341,6 +357,17 @@ class DataApiClient @Inject constructor( it.results ?: emptyList() } + override suspend fun getIncidentsNoAuth( + fields: List, + limit: Int, + ordering: String, + after: Instant?, + ) = networkApi.getIncidentsNoAuth(fields.joinToString(","), limit, ordering, after) + .let { + it.errors?.tryThrowException() + it.results ?: emptyList() + } + override suspend fun getIncidentsList( fields: List, limit: Int, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/WriteApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/WriteApiClient.kt index 8e158c126..4445132be 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/WriteApiClient.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/WriteApiClient.kt @@ -9,8 +9,10 @@ import com.crisiscleanup.core.network.model.NetworkFileUpload import com.crisiscleanup.core.network.model.NetworkFlag import com.crisiscleanup.core.network.model.NetworkFlagId import com.crisiscleanup.core.network.model.NetworkIncidentRedeployRequest +import com.crisiscleanup.core.network.model.NetworkLocationCoordinates import com.crisiscleanup.core.network.model.NetworkNote import com.crisiscleanup.core.network.model.NetworkNoteNote +import com.crisiscleanup.core.network.model.NetworkPointLocation import com.crisiscleanup.core.network.model.NetworkRequestRedeploy import com.crisiscleanup.core.network.model.NetworkShareDetails import com.crisiscleanup.core.network.model.NetworkType @@ -180,6 +182,12 @@ private interface DataChangeApi { suspend fun requestRedeploy( @Body redeployPayload: NetworkRequestRedeploy, ): NetworkIncidentRedeployRequest? + + @TokenAuthenticationHeader + @POST("user_geo_locations") + suspend fun shareLocation( + @Body pointLocation: NetworkPointLocation, + ): Response } interface FileUploadApi { @@ -326,4 +334,12 @@ class WriteApiClient @Inject constructor( incidentId, ), )?.let { it.organization == organizationId && it.incident == incidentId } == true + + override suspend fun shareLocation(latitude: Double, longitude: Double) { + writeApi.shareLocation( + NetworkPointLocation( + NetworkLocationCoordinates(listOf(latitude, longitude)), + ), + ) + } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt index b298848de..b3919ec5a 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/RootAuthViewModel.kt @@ -2,21 +2,24 @@ package com.crisiscleanup.feature.authentication import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.crisiscleanup.core.common.log.AppLogger -import com.crisiscleanup.core.common.log.CrisisCleanupLoggers -import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers +import com.crisiscleanup.core.common.network.Dispatcher import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.model.data.AccountData import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class RootAuthViewModel @Inject constructor( accountDataRepository: AccountDataRepository, - @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, + incidentsRepository: IncidentsRepository, + @Dispatcher(CrisisCleanupDispatchers.IO) ioDispatcher: CoroutineDispatcher, ) : ViewModel() { val authState = accountDataRepository.accountData .map { @@ -31,6 +34,19 @@ class RootAuthViewModel @Inject constructor( initialValue = AuthState.Loading, started = SharingStarted.WhileSubscribed(), ) + + val hotlineIncidents = incidentsRepository.hotlineIncidents + .stateIn( + scope = viewModelScope, + initialValue = emptyList(), + started = SharingStarted.WhileSubscribed(), + ) + + init { + viewModelScope.launch(ioDispatcher) { + incidentsRepository.pullHotlineIncidents() + } + } } sealed interface AuthState { diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt index bed887156..20a6abaa0 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/RootAuthScreen.kt @@ -2,18 +2,24 @@ package com.crisiscleanup.feature.authentication.ui import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler @@ -25,6 +31,8 @@ import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.BusyButton import com.crisiscleanup.core.designsystem.component.CrisisCleanupLogoRow import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton +import com.crisiscleanup.core.designsystem.component.HotlineHeaderView +import com.crisiscleanup.core.designsystem.component.HotlineIncidentView import com.crisiscleanup.core.designsystem.component.LinkifyText import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme @@ -35,6 +43,7 @@ import com.crisiscleanup.core.designsystem.theme.fillWidthPadded import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemPadding import com.crisiscleanup.core.model.data.AccountData +import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.ui.rememberCloseKeyboard import com.crisiscleanup.core.ui.rememberIsKeyboardOpen import com.crisiscleanup.core.ui.scrollFlingListener @@ -67,11 +76,11 @@ fun RootAuthRoute( @Composable internal fun RootAuthScreen( - viewModel: RootAuthViewModel = hiltViewModel(), openLoginWithEmail: () -> Unit = {}, openLoginWithPhone: () -> Unit = {}, openVolunteerOrg: () -> Unit = {}, closeAuthentication: () -> Unit = {}, + viewModel: RootAuthViewModel = hiltViewModel(), ) { val authState by viewModel.authState.collectAsStateWithLifecycle() when (authState) { @@ -114,12 +123,14 @@ internal fun RootAuthScreen( is AuthState.NotAuthenticated -> { val hasAuthenticated = (authState as AuthState.NotAuthenticated).hasAuthenticated + val hotlineIncidents by viewModel.hotlineIncidents.collectAsStateWithLifecycle() NotAuthenticatedScreen( openLoginWithEmail = openLoginWithEmail, openLoginWithPhone = openLoginWithPhone, openVolunteerOrg = openVolunteerOrg, closeAuthentication = closeAuthentication, hasAuthenticated = hasAuthenticated, + hotlineIncidents = hotlineIncidents, ) } } @@ -171,8 +182,10 @@ private fun NotAuthenticatedScreen( openVolunteerOrg: () -> Unit = {}, closeAuthentication: () -> Unit = {}, hasAuthenticated: Boolean = false, + hotlineIncidents: List = emptyList(), ) { val t = LocalAppTranslator.current + val translationCount by t.translationCount.collectAsStateWithLifecycle() val uriHandler = LocalUriHandler.current val iNeedHelpCleaningLink = "https://crisiscleanup.org/survivor" val closeKeyboard = rememberCloseKeyboard(openLoginWithEmail) @@ -185,6 +198,8 @@ private fun NotAuthenticatedScreen( ) { CrisisCleanupLogoRow() + HotlineIncidentsView(hotlineIncidents) + Text( modifier = listItemModifier.testTag("rootAuthLoginText"), text = t("actions.login", R.string.login), @@ -249,9 +264,12 @@ private fun NotAuthenticatedScreen( R.string.reliefOrgAndGovOnly, ), ) + val registerText = remember(translationCount) { + t("actions.register") + } LinkifyText( modifier = Modifier.testTag("rootAuthRegisterAction"), - linkText = t("actions.register"), + linkText = registerText, link = ORG_REGISTER_URL, ) } @@ -270,6 +288,37 @@ private fun NotAuthenticatedScreen( } } +@Composable +private fun HotlineIncidentsView( + incidents: List, +) { + if (incidents.isNotEmpty()) { + var expandHotline by remember { mutableStateOf(true) } + val toggleExpandHotline = { expandHotline = !expandHotline } + + // TODO Common dimensions + Column( + Modifier + .background(MaterialTheme.colorScheme.primaryContainer) + .padding(vertical = 8.dp), + ) { + HotlineHeaderView( + expandHotline, + toggleExpandHotline, + ) + if (expandHotline) { + for (incident in incidents) { + HotlineIncidentView( + incident.shortName, + incident.activePhoneNumbers, + linkifyNumbers = true, + ) + } + } + } + } +} + @DayNightPreviews @Composable private fun NotAuthenticatedScreenPreview() { diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt index 2778e5db6..25164e430 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/ViewCaseViewModel.kt @@ -47,6 +47,7 @@ import com.crisiscleanup.core.model.data.WorkType import com.crisiscleanup.core.model.data.WorkTypeRequest import com.crisiscleanup.core.model.data.Worksite import com.crisiscleanup.core.model.data.WorksiteFlag +import com.crisiscleanup.core.model.data.WorksiteFormValue import com.crisiscleanup.core.model.data.WorksiteNote import com.crisiscleanup.feature.caseeditor.model.coordinates import com.crisiscleanup.feature.caseeditor.navigation.ExistingCaseArgs @@ -420,17 +421,29 @@ class ViewCaseViewModel @Inject constructor( name = translate(workTypeLiteral) } val workTypeLookup = stateData.incident.workTypeLookup - val summaryJobTypes = worksite.formData - ?.asSequence() - ?.filter { formValue -> workTypeLookup[formValue.key] == workTypeLiteral } - ?.filter { formValue -> formValue.value.isBooleanTrue } - ?.map { formValue -> translate("formLabels.${formValue.key}") } - ?.filter { jobName -> jobName != name } - ?.filter(String::isNotBlank) - ?.toList() - ?: emptyList() + val workTypeValues = worksite.formData?.let { formData -> + formData.asSequence() + .filter { formValue -> workTypeLookup[formValue.key] == workTypeLiteral } + } ?: emptyMap().entries.asSequence() + val summaryJobTypes = workTypeValues + .filter { formValue -> formValue.value.isBooleanTrue } + .map { formValue -> translate("formLabels.${formValue.key}") } + .filter { jobName -> jobName != name } + .filter(String::isNotBlank) + .toList() + val summaryJobDetails = workTypeValues + .filterNot { formValue -> formValue.value.isBooleanTrue } + .map { formValue -> + val title = translate("formLabels.${formValue.key}") + val description = translate(formValue.value.valueString) + listOf(title, description).combineTrimText(": ") + } + .filter(String::isNotBlank) + .toList() + val summary = listOf( summaryJobTypes.combineTrimText(), + summaryJobDetails.combineTrimText("\n"), workType.recur?.let { rRuleString -> try { return@let RRule(rRuleString).toHumanReadableText(translator) diff --git a/feature/menu/build.gradle.kts b/feature/menu/build.gradle.kts index 308e609ef..96912b235 100644 --- a/feature/menu/build.gradle.kts +++ b/feature/menu/build.gradle.kts @@ -11,4 +11,6 @@ android { dependencies { implementation(projects.core.appComponent) implementation(projects.core.selectincident) + + implementation(libs.accompanist.permissions) } \ No newline at end of file diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index 25f81a0dd..9e416d62d 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -74,6 +74,13 @@ class MenuViewModel @Inject constructor( val loadSelectIncidents = appTopBarDataProvider.loadSelectIncidents val isLoadingIncidents = incidentsRepository.isLoading + val hotlineIncidents = incidentsRepository.hotlineIncidents + .stateIn( + scope = viewModelScope, + initialValue = emptyList(), + started = SharingStarted.WhileSubscribed(), + ) + val versionText: String get() { val version = appVersionProvider.version @@ -87,6 +94,10 @@ class MenuViewModel @Inject constructor( it.allowAllAnalytics } + val isSharingLocation = appPreferencesRepository.userPreferences.map { + it.shareLocationWithOrg + } + val menuItemVisibility = appPreferencesRepository.userPreferences .map { MenuItemVisibility( @@ -121,11 +132,17 @@ class MenuViewModel @Inject constructor( } fun shareAnalytics(share: Boolean) { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { appPreferencesRepository.setAnalytics(share) } } + fun shareLocationWithOrg(share: Boolean) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setShareLocationWithOrg(share) + } + } + fun simulateTokenExpired() { if (isDebuggable) { (accountDataRepository as CrisisCleanupAccountDataRepository).expireAccessToken() diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index 352757d3a..c6781da87 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -1,6 +1,8 @@ package com.crisiscleanup.feature.menu.ui +import android.Manifest.permission.ACCESS_COARSE_LOCATION import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -9,15 +11,19 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer 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 +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text @@ -46,6 +52,9 @@ import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.CrisisCleanupButton import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton import com.crisiscleanup.core.designsystem.component.CrisisCleanupTextButton +import com.crisiscleanup.core.designsystem.component.HotlineHeaderView +import com.crisiscleanup.core.designsystem.component.HotlineIncidentView +import com.crisiscleanup.core.designsystem.component.OpenSettingsDialog import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.component.actionRoundCornerShape import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons @@ -62,6 +71,10 @@ import com.crisiscleanup.core.selectincident.SelectIncidentDialog import com.crisiscleanup.core.ui.sizePosition import com.crisiscleanup.feature.menu.MenuViewModel import com.crisiscleanup.feature.menu.R +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.shouldShowRationale @Composable internal fun MenuRoute( @@ -82,6 +95,7 @@ internal fun MenuRoute( ) } +@OptIn(ExperimentalPermissionsApi::class) @Composable private fun MenuScreen( openAuthentication: () -> Unit = {}, @@ -102,9 +116,23 @@ private fun MenuScreen( } val isSharingAnalytics by viewModel.isSharingAnalytics.collectAsStateWithLifecycle(false) - val shareAnalytics = remember(viewModel) { + + val isSharingLocation by viewModel.isSharingLocation.collectAsStateWithLifecycle(false) + val locationPermission = rememberPermissionState(ACCESS_COARSE_LOCATION) + var explainLocationRequest by remember { mutableStateOf(false) } + val onShareLocation = remember(locationPermission) { { b: Boolean -> - viewModel.shareAnalytics(b) + if (b && !locationPermission.status.isGranted) { + with(locationPermission.status) { + if (shouldShowRationale) { + explainLocationRequest = true + } else { + locationPermission.launchPermissionRequest() + } + } + } else { + viewModel.shareLocationWithOrg(b) + } } } @@ -131,6 +159,24 @@ private fun MenuScreen( val isLoadingIncidents by viewModel.isLoadingIncidents.collectAsStateWithLifecycle(false) + var expandHotline by remember { mutableStateOf(false) } + val toggleExpandHotline = { expandHotline = !expandHotline } + + val hotlineIncidents by viewModel.hotlineIncidents.collectAsStateWithLifecycle() + val tutorialItemOffset = remember(hotlineIncidents, expandHotline) { + val incidentRows = if (expandHotline) { + hotlineIncidents.size + } else { + 0 + } + val headerSpacerCount = if (hotlineIncidents.isEmpty()) { + 0 + } else { + 3 + } + incidentRows + headerSpacerCount + } + Column { AppTopBar( incidentDropdownModifier = incidentDropdownModifier, @@ -154,7 +200,7 @@ private fun MenuScreen( val focusItemScrollOffset = (-72 * LocalDensity.current.density).toInt() LaunchedEffect(tutorialStep) { fun getListItemIndex(itemIndex: Int): Int { - var listItemIndex = itemIndex + var listItemIndex = itemIndex + tutorialItemOffset if (!isMenuTutorialDone) { listItemIndex += 1 } @@ -163,26 +209,21 @@ private fun MenuScreen( } return listItemIndex } + + suspend fun scrollToListItem(itemIndex: Int) { + val listItemIndex = getListItemIndex(itemIndex) + if (firstVisibleItemIndex > listItemIndex - 2 || + lastVisibleItemIndex < listItemIndex + 2 + ) { + lazyListState.scrollToItem(listItemIndex, focusItemScrollOffset) + } + } when (tutorialStep) { TutorialStep.MenuStart, TutorialStep.InviteTeammates, - -> { - val listItemIndex = getListItemIndex(2) - if (firstVisibleItemIndex > listItemIndex - 2 || - lastVisibleItemIndex < listItemIndex + 2 - ) { - lazyListState.scrollToItem(listItemIndex, focusItemScrollOffset) - } - } + -> scrollToListItem(2) - TutorialStep.ProvideAppFeedback -> { - val listItemIndex = getListItemIndex(4) - if (firstVisibleItemIndex > listItemIndex - 2 || - lastVisibleItemIndex < listItemIndex + 2 - ) { - lazyListState.scrollToItem(listItemIndex, focusItemScrollOffset) - } - } + TutorialStep.ProvideAppFeedback -> scrollToListItem(4) else -> {} } @@ -192,6 +233,12 @@ private fun MenuScreen( Modifier.weight(1f), state = lazyListState, ) { + hotlineItems( + hotlineIncidents, + expandHotline, + toggleExpandHotline, + ) + if (!isMenuTutorialDone) { item { MenuTutorial( @@ -291,24 +338,23 @@ private fun MenuScreen( ) } + toggleItem( + "actions.share_analytics", + isSharingAnalytics, + viewModel::shareAnalytics, + ) + + toggleItem( + "~~Share location with organization", + isSharingLocation, + onShareLocation, + ) + item { - Row( - Modifier - .clickable( - onClick = { shareAnalytics(!isSharingAnalytics) }, - ) - .then(listItemModifier), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - t("actions.share_analytics"), - Modifier.weight(1f), - ) - Switch( - checked = isSharingAnalytics, - onCheckedChange = shareAnalytics, - ) - } + TermsPrivacyView( + termsOfServiceUrl = viewModel.termsOfServiceUrl, + privacyPolicyUrl = viewModel.privacyPolicyUrl, + ) } if (viewModel.isDebuggable) { @@ -326,26 +372,6 @@ private fun MenuScreen( } } } - - // TODO Open in WebView? - val uriHandler = LocalUriHandler.current - Row( - listItemModifier, - horizontalArrangement = Arrangement.Center, - ) { - CrisisCleanupTextButton( - Modifier.actionHeight(), - text = t("publicNav.terms"), - ) { - uriHandler.openUri(viewModel.termsOfServiceUrl) - } - CrisisCleanupTextButton( - Modifier.actionHeight(), - text = t("nav.privacy"), - ) { - uriHandler.openUri(viewModel.privacyPolicyUrl) - } - } } if (showIncidentPicker) { @@ -367,6 +393,89 @@ private fun MenuScreen( isLoadingIncidents = isLoadingIncidents, ) } + + if (explainLocationRequest) { + val permissionExplanation = + t("~~Location access is needed for sharing your location with your organization. Grant access to location in Settings.") + OpenSettingsDialog( + t("info.allow_access_to_location"), + permissionExplanation, + confirmText = t("info.app_settings"), + dismissText = t("actions.close"), + ) { + explainLocationRequest = false + } + } +} + +private fun LazyListScope.toggleItem( + translateKey: String, + isToggledOn: Boolean, + onToggle: (Boolean) -> Unit, +) { + + item( + key = "toggle-$translateKey", + contentType = "toggle-item", + ) { + Row( + Modifier + .clickable( + onClick = { onToggle(!isToggledOn) }, + ) + .then(listItemModifier), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + LocalAppTranslator.current(translateKey), + Modifier.weight(1f), + ) + Switch( + checked = isToggledOn, + onCheckedChange = onToggle, + ) + } + } +} + +private fun LazyListScope.hotlineSpacerItem() { + item(contentType = "hotline-spacer") { + Box( + Modifier.background(MaterialTheme.colorScheme.primaryContainer) + .fillMaxWidth() + // TODO Common dimensions + .height(8.dp), + ) + } +} + +private fun LazyListScope.hotlineItems( + incidents: List, + expandHotline: Boolean, + toggleExpandHotline: () -> Unit, +) { + if (incidents.isNotEmpty()) { + hotlineSpacerItem() + + item { + HotlineHeaderView( + expandHotline, + toggleExpandHotline, + ) + } + + if (expandHotline) { + items( + incidents, + key = { "hotline-incident-${it.id}" }, + contentType = { "hotline-incident" }, + ) { + HotlineIncidentView(it.shortName, it.activePhoneNumbers) + } + } + + hotlineSpacerItem() + } } @Composable @@ -522,3 +631,29 @@ internal fun MenuScreenNonProductionView( text = "Sync full", ) } + +@Composable +private fun TermsPrivacyView( + termsOfServiceUrl: String, + privacyPolicyUrl: String, +) { + val t = LocalAppTranslator.current + val uriHandler = LocalUriHandler.current + Row( + listItemModifier, + horizontalArrangement = Arrangement.Center, + ) { + CrisisCleanupTextButton( + Modifier.actionHeight(), + text = t("publicNav.terms"), + ) { + uriHandler.openUri(termsOfServiceUrl) + } + CrisisCleanupTextButton( + Modifier.actionHeight(), + text = t("nav.privacy"), + ) { + uriHandler.openUri(privacyPolicyUrl) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5153db2a7..3c62585f4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -81,6 +81,7 @@ androidx-compose-ui-test = ["androidx-compose-ui-test", "androidx-compose-ui-tes [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } +accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" } accompanist-systemuicontroller = { group = "com.google.accompanist", name = "accompanist-systemuicontroller", version.ref = "accompanist" } accompanist-testharness = { group = "com.google.accompanist", name = "accompanist-testharness", version.ref = "accompanist" } android-desugarJdkLibs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "androidDesugarJdkLibs" }