From e8f4557b5e761d94d45d780d7dcaaf7776e4d77f Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 11 Dec 2023 13:08:30 -0500 Subject: [PATCH 1/7] Apply spotless --- .../java/com/crisiscleanup/core/network/model/NetworkUser.kt | 3 +-- .../feature/authentication/LoginWithPhoneViewModel.kt | 4 ++-- .../feature/authentication/MagicLinkLoginViewModel.kt | 3 +-- .../authentication/navigation/LoginWithEmailNavigation.kt | 2 +- .../authentication/navigation/PasswordRecoverNavigation.kt | 2 +- .../feature/authentication/ui/LoginWithEmailScreen.kt | 2 +- .../feature/authentication/ui/LoginWithPhoneScreen.kt | 2 +- .../feature/authentication/ui/MagicLinkLoginScreen.kt | 2 +- 8 files changed, 9 insertions(+), 11 deletions(-) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt index c26273d87..525d24372 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkUser.kt @@ -14,7 +14,6 @@ data class NetworkUser( val files: List, ) - @Serializable data class NetworkUserProfile( val id: Long, @@ -28,4 +27,4 @@ data class NetworkUserProfile( ) { val profilePicUrl: String? get() = files?.profilePictureUrl -} \ No newline at end of file +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt index e1a801044..ff30cbcb5 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt @@ -323,7 +323,7 @@ data class PhoneNumberAccount( val userId: Long, val userDisplayName: String, val organizationName: String, - val accountDisplay: String = if (userId > 0) "${userDisplayName}, $organizationName" else "", + val accountDisplay: String = if (userId > 0) "$userDisplayName, $organizationName" else "", ) val PhoneNumberAccountNone = PhoneNumberAccount(0, "", "") @@ -336,5 +336,5 @@ private data class PhoneCodeVerification( private enum class OneTimePasswordError { None, - InvalidCode + InvalidCode, } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt index b5d8ea2ca..c86938a2a 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt @@ -88,7 +88,6 @@ class MagicLinkLoginViewModel @Inject constructor( } } catch (e: Exception) { logger.logException(e) - } finally { isAuthenticating.value = false } @@ -104,4 +103,4 @@ class MagicLinkLoginViewModel @Inject constructor( fun clearMagicLinkLogin() { authEventBus.onEmailLoginLink("") } -} \ No newline at end of file +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt index 5f3dc137b..b8c74e02c 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/LoginWithEmailNavigation.kt @@ -44,4 +44,4 @@ fun NavGraphBuilder.magicLinkLoginScreen( closeAuthentication = closeAuthentication, ) } -} \ No newline at end of file +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt index 0ab0daa73..bc8683b8e 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/navigation/PasswordRecoverNavigation.kt @@ -54,4 +54,4 @@ fun NavGraphBuilder.resetPasswordScreen( closeResetPassword = closeResetPassword, ) } -} \ No newline at end of file +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt index 473816747..fd850fb2f 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt @@ -124,7 +124,7 @@ private fun LoginWithEmailScreen( val isNotBusy by viewModel.isNotAuthenticating.collectAsStateWithLifecycle() val focusEmail = viewModel.loginInputData.emailAddress.isEmpty() || - viewModel.isInvalidEmail.value + viewModel.isInvalidEmail.value val updateEmailInput = remember(viewModel) { { s: String -> viewModel.loginInputData.emailAddress = s } } val clearErrorVisuals = remember(viewModel) { { viewModel.clearErrorVisuals() } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt index 1eb7b3e89..102bb2013 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt @@ -419,4 +419,4 @@ private fun ColumnScope.VerifyPhoneCodeScreen( text = translator("actions.submit"), indicateBusy = isExchangingCode, ) -} \ No newline at end of file +} diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt index 2ea2b831c..a666f06e0 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt @@ -73,4 +73,4 @@ fun MagicLinkLoginRoute( ) } } -} \ No newline at end of file +} From 840fd81686ec4ced425ceb482605cd2c24b04843 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 11 Dec 2023 14:48:04 -0500 Subject: [PATCH 2/7] Update dependencies --- .../core/designsystem/component/FilterChip.kt | 22 -------- gradle/libs.versions.toml | 54 ++++++++++--------- 2 files changed, 28 insertions(+), 48 deletions(-) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/FilterChip.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/FilterChip.kt index d20e6b2dc..468426ff4 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/FilterChip.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/FilterChip.kt @@ -1,21 +1,17 @@ package com.crisiscleanup.core.designsystem.component import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ProvideTextStyle -import androidx.compose.material3.SelectableChipBorder import androidx.compose.material3.SelectableChipColors import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp import com.crisiscleanup.core.designsystem.theme.disabledAlpha -@OptIn(ExperimentalMaterial3Api::class) @Composable fun SelectableFilterChip( selected: Boolean, @@ -25,7 +21,6 @@ fun SelectableFilterChip( leadingIcon: (@Composable () -> Unit)? = null, label: @Composable () -> Unit, textStyle: TextStyle = MaterialTheme.typography.bodySmall, - chipBorder: SelectableChipBorder = ChipDefaults.filterChipBorder(), chipColors: SelectableChipColors = ChipDefaults.filterChipColors(selected), ) = FilterChip( selected = selected, @@ -39,11 +34,9 @@ fun SelectableFilterChip( enabled = enabled, leadingIcon = leadingIcon, shape = CircleShape, - border = chipBorder, colors = chipColors, ) -@OptIn(ExperimentalMaterial3Api::class) @Composable fun CrisisCleanupFilterChip( selected: Boolean, @@ -67,22 +60,7 @@ private object ChipDefaults { // TODO: File bug // FilterChip default values aren't exposed via FilterChipDefaults const val DisabledChipContainerAlpha = 0.12f - val ChipBorderWidth = 1.dp - @OptIn(ExperimentalMaterial3Api::class) - @Composable - fun filterChipBorder(): SelectableChipBorder { - val colors = MaterialTheme.colorScheme - return FilterChipDefaults.filterChipBorder( - borderColor = colors.onBackground, - selectedBorderColor = colors.primaryContainer, - disabledBorderColor = colors.onBackground.disabledAlpha(), - disabledSelectedBorderColor = colors.onBackground.disabledAlpha(), - selectedBorderWidth = ChipBorderWidth, - ) - } - - @OptIn(ExperimentalMaterial3Api::class) @Composable fun filterChipColors(selected: Boolean): SelectableChipColors { val colors = MaterialTheme.colorScheme diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01bf26301..223eb73f3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,27 +1,27 @@ [versions] accompanist = "0.31.2-alpha" -androidDesugarJdkLibs = "2.0.3" +androidDesugarJdkLibs = "2.0.4" androidGradlePlugin = "8.1.1" androidMapsUtil = "2.3.0" androidMapsUtilKtx = "3.4.0" -androidMaterial = "1.9.0" -androidxActivity = "1.7.2" +androidMaterial = "1.10.0" +androidxActivity = "1.8.1" androidxAppCompat = "1.6.1" -androidxBrowser = "1.6.0" -androidxComposeBom = "2023.09.00" -androidxComposeCompiler = "1.5.0" -androidxComposeMaterial3 = "1.2.0-alpha07" -androidxComposeRuntimeTracing = "1.0.0-alpha04" -androidxConstraintLayout = "1.1.0-alpha12" +androidxBrowser = "1.7.0" +androidxComposeBom = "2023.10.01" +androidxComposeCompiler = "1.5.6" +androidxComposeMaterial3 = "1.2.0-alpha12" +androidxComposeRuntimeTracing = "1.0.0-beta01" +androidxConstraintLayout = "1.1.0-alpha13" androidxCore = "1.12.0" androidxCoreSplashscreen = "1.0.1" androidxDataStore = "1.0.0" androidxEspresso = "3.5.1" -androidxHiltNavigationCompose = "1.0.0" +androidxHiltNavigationCompose = "1.1.0" androidxLifecycle = "2.6.2" -androidxMacroBenchmark = "1.1.1" +androidxMacroBenchmark = "1.2.2" androidxMetrics = "1.0.0-alpha04" -androidxNavigation = "2.7.2" +androidxNavigation = "2.7.5" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxSecurityCrypto = "1.1.0-alpha06" @@ -29,41 +29,42 @@ androidxTestCore = "1.5.0" androidxTestExt = "1.1.5" androidxTestRules = "1.5.0" androidxTestRunner = "1.5.2" -androidxTracing = "1.1.0" +androidxTracing = "1.2.0" androidxUiAutomator = "2.2.0" -androidxWindowManager = "1.1.0" -androidxWork = "2.8.1" +androidxWindowManager = "1.2.0" +androidxWork = "2.9.0" apacheCommonsText = "1.10.0" coil = "2.3.0" -firebaseAppDistribution = "16.0.0-beta10" -firebaseBom = "32.3.1" +firebaseAppDistribution = "16.0.0-beta11" +firebaseBom = "32.7.0" firebaseCrashlyticsPlugin = "2.9.9" firebasePerfPlugin = "1.4.2" -gmsPlugin = "4.3.14" +gmsPlugin = "4.4.0" googleMapsCompose = "2.9.1" -googlePlaces = "3.2.0" -hilt = "2.47" -hiltExt = "1.0.0" +googlePlaces = "3.3.0" +hilt = "2.48" +hiltExt = "1.1.0" jacoco = "0.8.7" junit4 = "4.13.2" jwtDecode = "2.0.1" -kotlin = "1.9.0" +kotlin = "1.9.21" kotlinxCoroutines = "1.7.1" kotlinxCoroutinesPlayServices = "1.7.1" kotlinxDatetime = "0.4.0" kotlinxSerializationJson = "1.5.1" -ksp = "1.9.0-1.0.11" -lint = "31.1.1" +ksp = "1.9.21-1.0.15" +lint = "31.2.0" mockk = "1.13.5" okhttp = "4.11.0" philJayRrule = "1.0.3" playServicesLocation = "21.0.1" -playServicesMaps = "18.1.0" +playServicesMaps = "18.2.0" protobuf = "3.23.4" protobufPlugin = "0.9.1" +qrCodeKotlin = "4.0.7" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" -room = "2.5.2" +room = "2.6.1" secrets = "2.0.1" squareSeismic = "1.0.3" timeAgo = "4.0.3" @@ -156,6 +157,7 @@ playservices-location = { group = "com.google.android.gms", name = "play-service playservices-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +qrcode-kotlin = { group = "io.github.g0dkar", name = "qrcode-kotlin-jvm", version.ref = "qrCodeKotlin" } retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } From 4179e0c2b29b793eb770e095862294fe85aec1a0 Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 11 Dec 2023 15:00:42 -0500 Subject: [PATCH 3/7] Start on invite teammate --- core/common/build.gradle.kts | 1 + .../core/common/QrCodeGenerator.kt | 19 +++++++++++++++++++ .../core/common/di/ApplicationModule.kt | 5 +++++ feature/organizationmanage/.gitignore | 1 + feature/organizationmanage/build.gradle.kts | 13 +++++++++++++ .../src/main/java/AndroidManifest.xml | 1 + .../InviteTeammateViewModel.kt | 12 ++++++++++++ .../di/OrganizationManageModule.kt | 10 ++++++++++ .../navigation/InviteTeammateNavigation.kt | 2 ++ .../ui/InviteTeammateScreen.kt | 10 ++++++++++ settings.gradle.kts | 5 +++-- 11 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt create mode 100644 feature/organizationmanage/.gitignore create mode 100644 feature/organizationmanage/build.gradle.kts create mode 100644 feature/organizationmanage/src/main/java/AndroidManifest.xml create mode 100644 feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt create mode 100644 feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt create mode 100644 feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt create mode 100644 feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 1b755a981..4f63fb20a 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -13,5 +13,6 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) implementation(libs.timeago) + implementation(libs.qrcode.kotlin) testImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt b/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt new file mode 100644 index 000000000..b7d07de5b --- /dev/null +++ b/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt @@ -0,0 +1,19 @@ +package com.crisiscleanup.core.common + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import qrcode.QRCode + +interface QrCodeGenerator { + fun generate(payload: String): Bitmap? +} + +class QrCodeKotlinGenerator : QrCodeGenerator { + override fun generate(payload: String): Bitmap? { + val code = QRCode.ofSquares() + .build(payload) + + val pngBytes = code.renderToBytes() + return BitmapFactory.decodeByteArray(pngBytes, 0, pngBytes.size) + } +} \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt index 030f2261f..5814ea15d 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt @@ -41,4 +41,9 @@ interface ApplicationModule { fun bindsTranslator( translator: AndroidResourceTranslator, ): KeyResourceTranslator + + @Binds + fun bindsQrCodeGenerator( + generator: QrCodeKotlinGenerator, + ): QrCodeGenerator } diff --git a/feature/organizationmanage/.gitignore b/feature/organizationmanage/.gitignore new file mode 100644 index 000000000..42afabfd2 --- /dev/null +++ b/feature/organizationmanage/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/organizationmanage/build.gradle.kts b/feature/organizationmanage/build.gradle.kts new file mode 100644 index 000000000..9fd8fa85b --- /dev/null +++ b/feature/organizationmanage/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("nowinandroid.android.feature") + id("nowinandroid.android.library.compose") + id("nowinandroid.android.library.jacoco") +} + +android { + namespace = "com.crisiscleanup.feature.organizationmanage" +} + +dependencies { + implementation(project(":core:data")) +} \ No newline at end of file diff --git a/feature/organizationmanage/src/main/java/AndroidManifest.xml b/feature/organizationmanage/src/main/java/AndroidManifest.xml new file mode 100644 index 000000000..25df38676 --- /dev/null +++ b/feature/organizationmanage/src/main/java/AndroidManifest.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt new file mode 100644 index 000000000..92837eb3c --- /dev/null +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt @@ -0,0 +1,12 @@ +package com.crisiscleanup.feature.organizationmanage + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +@HiltViewModel +class InviteTeammateViewModel @Inject constructor( + +) : ViewModel() { + +} \ No newline at end of file diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt new file mode 100644 index 000000000..7a6c05f02 --- /dev/null +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt @@ -0,0 +1,10 @@ +package com.crisiscleanup.feature.organizationmanage.di + +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface OrganizationManageModule { +} diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt new file mode 100644 index 000000000..a897c7052 --- /dev/null +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt @@ -0,0 +1,2 @@ +package com.crisiscleanup.feature.organizationmanage.navigation + diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt new file mode 100644 index 000000000..5486a022a --- /dev/null +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt @@ -0,0 +1,10 @@ +package com.crisiscleanup.feature.organizationmanage.ui + +import androidx.compose.runtime.Composable + +@Composable +fun InviteTeammateRoute( + +) { + +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 2a04972fc..d8e0f4d15 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -42,11 +42,12 @@ include(":feature:authentication") include(":feature:caseeditor") include(":feature:cases") include(":feature:dashboard") -include(":feature:menu") include(":feature:mediamanage") +include(":feature:menu") +include(":feature:organizationmanage") include(":feature:syncinsights") -include(":feature:userfeedback") include(":feature:team") +include(":feature:userfeedback") include(":lint") include(":sync:work") include(":sync:sync-test") From 6ffa614b8065e84a66c7b36814353b67285d6c1b Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 12 Dec 2023 11:29:41 -0500 Subject: [PATCH 4/7] Refactor app settings into provider for singular access --- .../com/crisiscleanup/CrisisCleanupAppEnv.kt | 7 +++-- core/addresssearch/build.gradle.kts | 8 ------ .../GooglePlaceAddressSearchRepository.kt | 4 ++- core/common/build.gradle.kts | 8 ++++++ .../core/common/AppSettingsProvider.kt | 26 +++++++++++++++++++ .../core/common/di/ApplicationModule.kt | 5 ++++ feature/authentication/build.gradle.kts | 8 ------ .../authentication/AuthenticationViewModel.kt | 4 +++ .../authentication/ui/LoginWithEmailScreen.kt | 5 ++-- feature/caseeditor/build.gradle.kts | 9 ------- feature/cases/build.gradle.kts | 8 ------ 11 files changed, 53 insertions(+), 39 deletions(-) create mode 100644 core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt diff --git a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt index b1743439f..f6e461867 100644 --- a/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt +++ b/app/src/main/java/com/crisiscleanup/CrisisCleanupAppEnv.kt @@ -1,11 +1,14 @@ package com.crisiscleanup import com.crisiscleanup.core.common.AppEnv +import com.crisiscleanup.core.common.AppSettingsProvider import javax.inject.Inject import javax.inject.Singleton @Singleton -class CrisisCleanupAppEnv @Inject constructor() : AppEnv { +class CrisisCleanupAppEnv @Inject constructor( + private val settingsProvider: AppSettingsProvider, +) : AppEnv { override val isDebuggable = !(BuildConfig.IS_RELEASE_BUILD || BuildConfig.IS_PROD_BUILD) override val isProduction = BuildConfig.IS_RELEASE_BUILD && BuildConfig.IS_PROD_BUILD override val isNotProduction = !isProduction @@ -14,7 +17,7 @@ class CrisisCleanupAppEnv @Inject constructor() : AppEnv { override val apiEnvironment: String get() { - val apiUrl = BuildConfig.API_BASE_URL + val apiUrl = settingsProvider.apiBaseUrl return when { apiUrl.startsWith("https://api.dev.crisiscleanup.io") -> "Dev" apiUrl.startsWith("https://api.staging.crisiscleanup.io") -> "Staging" diff --git a/core/addresssearch/build.gradle.kts b/core/addresssearch/build.gradle.kts index 2ea023288..877c351f7 100644 --- a/core/addresssearch/build.gradle.kts +++ b/core/addresssearch/build.gradle.kts @@ -2,20 +2,12 @@ plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") id("nowinandroid.android.hilt") - id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { - buildFeatures { - buildConfig = true - } namespace = "com.crisiscleanup.core.addresssearch" } -secrets { - defaultPropertiesFileName = "secrets.defaults.properties" -} - dependencies { implementation(project(":core:common")) implementation(project(":core:model")) 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 e15ec8b37..8bf02915a 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 @@ -4,6 +4,7 @@ import android.content.Context import android.location.Geocoder import android.util.LruCache import com.crisiscleanup.core.addresssearch.model.KeySearchAddress +import com.crisiscleanup.core.common.AppSettingsProvider import com.crisiscleanup.core.common.combineTrimText import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers @@ -36,6 +37,7 @@ import kotlin.time.Duration.Companion.hours class GooglePlaceAddressSearchRepository @Inject constructor( @ApplicationContext private val context: Context, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, + private val settingsProvider: AppSettingsProvider, ) : AddressSearchRepository { private val geocoder = Geocoder(context) @@ -44,7 +46,7 @@ class GooglePlaceAddressSearchRepository @Inject constructor( private suspend fun placesClient(): PlacesClient { placesClientMutex.withLock { if (_placesClient == null) { - Places.initialize(context, BuildConfig.MAPS_API_KEY) + Places.initialize(context, settingsProvider.mapsApiKey) _placesClient = Places.createClient(context) } } diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 4f63fb20a..98597b838 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -2,12 +2,20 @@ plugins { id("nowinandroid.android.library") id("nowinandroid.android.library.jacoco") id("nowinandroid.android.hilt") + id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { + buildFeatures { + buildConfig = true + } namespace = "com.crisiscleanup.core.common" } +secrets { + defaultPropertiesFileName = "secrets.defaults.properties" +} + dependencies { implementation(libs.androidx.core.ktx) implementation(libs.kotlinx.coroutines.android) diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt b/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt new file mode 100644 index 000000000..3d56ba618 --- /dev/null +++ b/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt @@ -0,0 +1,26 @@ +package com.crisiscleanup.core.common + +import javax.inject.Inject + +interface AppSettingsProvider { + val apiBaseUrl: String + val baseUrl: String + val mapsApiKey: String + + val debugEmail: String + val debugPassword: String +} + +class SecretsAppSettingsProvider @Inject constructor() : AppSettingsProvider { + override val apiBaseUrl: String + get() = BuildConfig.API_BASE_URL + override val baseUrl: String + get() = BuildConfig.BASE_URL + override val mapsApiKey: String + get() = BuildConfig.MAPS_API_KEY + + override val debugEmail: String + get() = BuildConfig.DEBUG_EMAIL_ADDRESS + override val debugPassword: String + get() = BuildConfig.DEBUG_ACCOUNT_PASSWORD +} \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt index 5814ea15d..d0ac0a54d 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt @@ -12,6 +12,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) interface ApplicationModule { + @Binds + fun bindsSettingsProvider( + provider: SecretsAppSettingsProvider, + ): AppSettingsProvider + @Singleton @Binds fun bindsAndroidResourceProvider( diff --git a/feature/authentication/build.gradle.kts b/feature/authentication/build.gradle.kts index cf8224c37..3e66b3105 100644 --- a/feature/authentication/build.gradle.kts +++ b/feature/authentication/build.gradle.kts @@ -2,20 +2,12 @@ plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") id("nowinandroid.android.library.jacoco") - id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { - buildFeatures { - buildConfig = true - } namespace = "com.crisiscleanup.feature.authentication" } -secrets { - defaultPropertiesFileName = "secrets.defaults.properties" -} - dependencies { implementation(project(":core:common")) implementation(project(":core:datastore")) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt index b0ebfa848..3b7291eda 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/AuthenticationViewModel.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.AppEnv +import com.crisiscleanup.core.common.AppSettingsProvider import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.event.AuthEventBus @@ -40,10 +41,13 @@ class AuthenticationViewModel @Inject constructor( private val authEventBus: AuthEventBus, private val translator: KeyResourceTranslator, appEnv: AppEnv, + settingsProvider: AppSettingsProvider, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Auth) private val logger: AppLogger, ) : ViewModel() { val isDebug = appEnv.isDebuggable + val debugEmail = if (isDebug) settingsProvider.debugEmail else "" + val debugPassword = if (isDebug) settingsProvider.debugPassword else "" private var isAuthenticating = MutableStateFlow(false) val isNotAuthenticating = isAuthenticating.map(Boolean::not).stateIn( diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt index fd850fb2f..208cec587 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt @@ -39,7 +39,6 @@ import com.crisiscleanup.core.ui.rememberIsKeyboardOpen import com.crisiscleanup.core.ui.scrollFlingListener import com.crisiscleanup.feature.authentication.AuthenticateScreenUiState import com.crisiscleanup.feature.authentication.AuthenticationViewModel -import com.crisiscleanup.feature.authentication.BuildConfig import com.crisiscleanup.feature.authentication.R import com.crisiscleanup.feature.authentication.model.AuthenticationState @@ -189,8 +188,8 @@ private fun LoginWithEmailScreen( val rememberDebugAuthenticate = remember(viewModel) { { viewModel.loginInputData.apply { - emailAddress = BuildConfig.DEBUG_EMAIL_ADDRESS - password = BuildConfig.DEBUG_ACCOUNT_PASSWORD + emailAddress = viewModel.debugEmail + password = viewModel.debugPassword } viewModel.authenticateEmailPassword() } diff --git a/feature/caseeditor/build.gradle.kts b/feature/caseeditor/build.gradle.kts index 1d9e92c75..e75e7a613 100644 --- a/feature/caseeditor/build.gradle.kts +++ b/feature/caseeditor/build.gradle.kts @@ -2,7 +2,6 @@ plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") id("nowinandroid.android.library.jacoco") - id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { @@ -10,10 +9,6 @@ android { testInstrumentationRunner = "com.crisiscleanup.core.testing.CrisisCleanupTestRunner" } - buildFeatures { - buildConfig = true - } - testOptions { unitTests { isIncludeAndroidResources = true @@ -23,10 +18,6 @@ android { namespace = "com.crisiscleanup.feature.caseeditor" } -secrets { - defaultPropertiesFileName = "secrets.defaults.properties" -} - dependencies { implementation(project(":core:addresssearch")) implementation(project(":core:commonassets")) diff --git a/feature/cases/build.gradle.kts b/feature/cases/build.gradle.kts index 741316f15..5cdad6d6a 100644 --- a/feature/cases/build.gradle.kts +++ b/feature/cases/build.gradle.kts @@ -2,20 +2,12 @@ plugins { id("nowinandroid.android.feature") id("nowinandroid.android.library.compose") id("nowinandroid.android.library.jacoco") - id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } android { - buildFeatures { - buildConfig = true - } namespace = "com.crisiscleanup.feature.cases" } -secrets { - defaultPropertiesFileName = "secrets.defaults.properties" -} - dependencies { implementation(project(":core:commonassets")) implementation(project(":core:commoncase")) From d2cbb8c520c4a6adf42b34d25110bc7971e74cf0 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 12 Dec 2023 12:20:29 -0500 Subject: [PATCH 5/7] Add related data for inviting teammates --- .../crisiscleanup/ExternalIntentProcessor.kt | 8 +- .../crisiscleanup/MainActivityViewModel.kt | 8 +- .../core/common/di/ApplicationModule.kt | 8 ++ .../core/common/di/LoggersModule.kt | 7 ++ .../core/common/event/AuthEventBus.kt | 31 ------ .../core/common/event/ExternalEventBus.kt | 95 +++++++++++++++++++ .../core/common/log/AppLogger.kt | 1 + .../data/repository/OrgVolunteerRepository.kt | 74 +++++++++++++++ .../core/model/data/InvitationRequest.kt | 34 +++++++ .../core/model/data/JoinOrgInvite.kt | 20 ++++ .../core/model/data/OrgUserInviteInfo.kt | 24 +++++ .../authentication/MagicLinkLoginViewModel.kt | 8 +- .../PasswordRecoverViewModel.kt | 8 +- .../InviteTeammateViewModel.kt | 27 +++++- 14 files changed, 305 insertions(+), 48 deletions(-) create mode 100644 core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt create mode 100644 core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt diff --git a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt index 9eb4ecab8..ab42158dd 100644 --- a/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt +++ b/app/src/main/java/com/crisiscleanup/ExternalIntentProcessor.kt @@ -2,14 +2,14 @@ package com.crisiscleanup import android.content.Intent import android.net.Uri -import com.crisiscleanup.core.common.event.AuthEventBus +import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger import javax.inject.Inject class ExternalIntentProcessor @Inject constructor( - private val authEventBus: AuthEventBus, + private val externalEventBus: ExternalEventBus, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) { fun processMainIntent(intent: Intent): Boolean { @@ -34,12 +34,12 @@ class ExternalIntentProcessor @Inject constructor( if (urlPath.startsWith("/l/")) { val code = urlPath.replace("/l/", "") if (code.isNotBlank()) { - authEventBus.onEmailLoginLink(code) + externalEventBus.onEmailLoginLink(code) } } else if (urlPath.startsWith("/password/reset/")) { val code = urlPath.replace("/password/reset/", "") if (code.isNotBlank()) { - authEventBus.onResetPassword(code) + externalEventBus.onResetPassword(code) } } else { return false diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 5313f2f28..59c5d62a4 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -7,7 +7,7 @@ import com.crisiscleanup.core.appheader.AppHeaderUiState import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.AppVersionProvider import com.crisiscleanup.core.common.KeyResourceTranslator -import com.crisiscleanup.core.common.event.AuthEventBus +import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -68,7 +68,7 @@ class MainActivityViewModel @Inject constructor( private val appVersionProvider: AppVersionProvider, private val appEnv: AppEnv, firebaseAnalytics: FirebaseAnalytics, - authEventBus: AuthEventBus, + externalEventBus: ExternalEventBus, @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.App) private val logger: AppLogger, ) : ViewModel() { @@ -164,8 +164,8 @@ class MainActivityViewModel @Inject constructor( return null } - val showPasswordReset = authEventBus.showResetPassword - val showMagicLinkLogin = authEventBus.showMagicLinkLogin + val showPasswordReset = externalEventBus.showResetPassword + val showMagicLinkLogin = externalEventBus.showMagicLinkLogin val isSwitchingToProduction: StateFlow val productionSwitchMessage: StateFlow diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt index d0ac0a54d..4e688df27 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt @@ -1,7 +1,9 @@ package com.crisiscleanup.core.common.di import com.crisiscleanup.core.common.* +import com.crisiscleanup.core.common.event.CrisisCleanupExternalEventBus import com.crisiscleanup.core.common.event.CrisisCleanupTrimMemoryEventManager +import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.event.TrimMemoryEventManager import dagger.Binds import dagger.Module @@ -47,6 +49,12 @@ interface ApplicationModule { translator: AndroidResourceTranslator, ): KeyResourceTranslator + @Singleton + @Binds + fun bindsExternalEventBus( + bus: CrisisCleanupExternalEventBus, + ): ExternalEventBus + @Binds fun bindsQrCodeGenerator( generator: QrCodeKotlinGenerator, diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt index 7ad0b63ee..cdecbcba2 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/LoggersModule.kt @@ -54,6 +54,13 @@ object LoggersModule { return logger } + @Provides + @Logger(CrisisCleanupLoggers.Onboarding) + fun providesOnboardingLogger(logger: TagLogger): AppLogger { + logger.tag = "onboarding" + return logger + } + @Provides @Logger(CrisisCleanupLoggers.Sync) fun providesSyncLogger(logger: TagLogger): AppLogger { diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt b/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt index 75d5e0622..e3acfc01f 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/event/AuthEventBus.kt @@ -4,9 +4,6 @@ import com.crisiscleanup.core.common.di.ApplicationScope import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import javax.inject.Inject import javax.inject.Singleton @@ -15,19 +12,9 @@ interface AuthEventBus { val logouts: Flow val refreshedTokens: Flow - val showResetPassword: Flow - val resetPasswords: StateFlow - - val showMagicLinkLogin: Flow - val emailLoginCodes: Flow - fun onLogout() fun onTokensRefreshed() - - fun onResetPassword(code: String) - - fun onEmailLoginLink(code: String) } @Singleton @@ -37,12 +24,6 @@ class CrisisCleanupAuthEventBus @Inject constructor( override val logouts = MutableSharedFlow(0) override val refreshedTokens = MutableSharedFlow(0) - override val resetPasswords = MutableStateFlow("") - override val showResetPassword = resetPasswords.map { it.isNotBlank() } - - override val emailLoginCodes = MutableStateFlow("") - override val showMagicLinkLogin = emailLoginCodes.map { it.isNotBlank() } - override fun onLogout() { externalScope.launch { logouts.emit(true) @@ -54,16 +35,4 @@ class CrisisCleanupAuthEventBus @Inject constructor( refreshedTokens.emit(true) } } - - override fun onResetPassword(code: String) { - externalScope.launch { - resetPasswords.value = code - } - } - - override fun onEmailLoginLink(code: String) { - externalScope.launch { - emailLoginCodes.emit(code) - } - } } diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt b/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt new file mode 100644 index 000000000..fce500410 --- /dev/null +++ b/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt @@ -0,0 +1,95 @@ +package com.crisiscleanup.core.common.event + +import com.crisiscleanup.core.common.di.ApplicationScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +interface ExternalEventBus { + val showResetPassword: Flow + val resetPasswords: StateFlow + + val showMagicLinkLogin: Flow + val emailLoginCodes: Flow + + val showOrgUserInvite: Flow + val orgUserInvites: Flow + + val showOrgPersistentInvite: Flow + val orgPersistentInvites: Flow + + fun onResetPassword(code: String) + + fun onEmailLoginLink(code: String) + + fun onOrgUserInvite(code: String) + + fun onOrgPersistentInvite(inviterUserId: Long, inviteToken: String) + fun onOrgPersistentInvite(query: Map): Boolean +} + +@Singleton +class CrisisCleanupExternalEventBus @Inject constructor( + @ApplicationScope private val externalScope: CoroutineScope, +) : ExternalEventBus { + override val resetPasswords = MutableStateFlow("") + override val showResetPassword = resetPasswords.map(String::isNotBlank) + + override val emailLoginCodes = MutableStateFlow("") + override val showMagicLinkLogin = emailLoginCodes.map(String::isNotBlank) + + override val orgUserInvites = MutableStateFlow("") + override val showOrgUserInvite = orgUserInvites.map(String::isNotBlank) + + override val orgPersistentInvites = MutableStateFlow(UserPersistentInvite(0, "")) + override val showOrgPersistentInvite = orgPersistentInvites.map { it.inviterUserId > 0 } + + override fun onResetPassword(code: String) { + externalScope.launch { + resetPasswords.value = code + } + } + + override fun onEmailLoginLink(code: String) { + externalScope.launch { + emailLoginCodes.value = code + } + } + + override fun onOrgUserInvite(code: String) { + externalScope.launch { + orgUserInvites.value = code + } + } + + override fun onOrgPersistentInvite(inviterUserId: Long, inviteToken: String) { + externalScope.launch { + orgPersistentInvites.value = UserPersistentInvite(inviterUserId, inviteToken) + } + } + + override fun onOrgPersistentInvite(query: Map): Boolean { + query["user-id"]?.let { userIdString -> + try { + val userId = userIdString.toLong() + query["invite-token"]?.let { token -> + onOrgPersistentInvite(userId, token) + return true + } + } catch (e: Exception) { + // Unnecessary + } + } + return false + } +} + +data class UserPersistentInvite( + val inviterUserId: Long, + val inviteToken: String, +) \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt b/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt index 8e0f37fb3..cba0f18ad 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/log/AppLogger.kt @@ -28,6 +28,7 @@ enum class CrisisCleanupLoggers { Media, Navigation, Network, + Onboarding, Sync, Token, Worksites, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt new file mode 100644 index 000000000..ef44fb7eb --- /dev/null +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt @@ -0,0 +1,74 @@ +package com.crisiscleanup.core.data.repository + +import com.crisiscleanup.core.common.event.UserPersistentInvite +import com.crisiscleanup.core.model.data.CodeInviteAccept +import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo +import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.JoinOrgInvite +import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgUserInviteInfo +import javax.inject.Inject + +interface OrgVolunteerRepository { + suspend fun requestInvitation(invite: InvitationRequest): InvitationRequestResult? + suspend fun getInvitationInfo(inviteCode: String): OrgUserInviteInfo? + suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? + suspend fun acceptInvitation(invite: CodeInviteAccept): JoinOrgResult + + suspend fun getOrganizationInvite(organizationId: Long, inviterUserId: Long): JoinOrgInvite + suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult + + suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): Boolean + suspend fun createOrganization( + referer: String, + invite: IncidentOrganizationInviteInfo, + ): Boolean +} + +class CrisisCleanupOrgVolunteerRepository @Inject constructor() : OrgVolunteerRepository { + override suspend fun requestInvitation(invite: InvitationRequest): InvitationRequestResult? { + TODO("Not yet implemented") + } + + override suspend fun getInvitationInfo(inviteCode: String): OrgUserInviteInfo? { + TODO("Not yet implemented") + } + + override suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? { + TODO("Not yet implemented") + } + + override suspend fun acceptInvitation(invite: CodeInviteAccept): JoinOrgResult { + TODO("Not yet implemented") + } + + override suspend fun getOrganizationInvite( + organizationId: Long, + inviterUserId: Long, + ): JoinOrgInvite { + TODO("Not yet implemented") + } + + override suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult { + TODO("Not yet implemented") + } + + override suspend fun inviteToOrganization( + emailAddress: String, + organizationId: Long?, + ): Boolean { + TODO("Not yet implemented") + } + + override suspend fun createOrganization( + referer: String, + invite: IncidentOrganizationInviteInfo, + ): Boolean { + TODO("Not yet implemented") + } +} + +data class InvitationRequestResult( + val organizationName: String, + val organizationRecipient: String, +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt new file mode 100644 index 000000000..b782ae380 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt @@ -0,0 +1,34 @@ +package com.crisiscleanup.core.model.data + +data class InvitationRequest( + val firstName: String, + val lastName: String, + val emailAddress: String, + val title: String, + val password: String, + val mobile: String, + val languageId: Long, + + val inviterEmailAddress: String, +) + +data class IncidentOrganizationInviteInfo( + val incidentId: Long, + val organizationName: String, + val emailAddress: String, + val mobile: String, + val firstName: String, + val lastName: String, +) + +data class CodeInviteAccept( + val firstName: String, + val lastName: String, + val emailAddress: String, + val title: String, + val password: String, + val mobile: String, + val languageId: Long, + + val invitationCode: String, +) \ No newline at end of file diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt new file mode 100644 index 000000000..99866a194 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt @@ -0,0 +1,20 @@ +package com.crisiscleanup.core.model.data + +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant + +data class JoinOrgInvite( + val token: String, + val orgId: Long, + val expiresAt: Instant, + val isExpired: Boolean = expiresAt < Clock.System.now(), +) + +enum class JoinOrgResult { + Success, + + // Already joined + Redundant, + PendingAdditionalAction, + Unknown +} diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt new file mode 100644 index 000000000..e17ac3c52 --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt @@ -0,0 +1,24 @@ +package com.crisiscleanup.core.model.data + +import kotlinx.datetime.Instant +import java.net.URL + +data class OrgUserInviteInfo( + val displayName: String, + val inviterEmail: String, + val inviterAvatarUrl: URL?, + val invitedEmail: String, + val orgName: String, + val expiration: Instant, + val isExpiredInvite: Boolean, +) + +internal val ExpiredNetworkOrgInvite = OrgUserInviteInfo( + displayName = "", + inviterEmail = "", + inviterAvatarUrl = null, + invitedEmail = "", + orgName = "", + expiration = Instant.fromEpochSeconds(0), + isExpiredInvite = true, +) diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt index c86938a2a..de159ebf0 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt @@ -6,7 +6,7 @@ import androidx.compose.runtime.setValue import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.KeyResourceTranslator -import com.crisiscleanup.core.common.event.AuthEventBus +import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -31,7 +31,7 @@ class MagicLinkLoginViewModel @Inject constructor( authApi: CrisisCleanupAuthApi, dataApi: CrisisCleanupNetworkDataSource, private val translator: KeyResourceTranslator, - private val authEventBus: AuthEventBus, + private val externalEventBus: ExternalEventBus, @Dispatcher(CrisisCleanupDispatchers.IO) ioDispatcher: CoroutineDispatcher, @Logger(CrisisCleanupLoggers.Account) private val logger: AppLogger, ) : ViewModel() { @@ -45,7 +45,7 @@ class MagicLinkLoginViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { var message = "" try { - val loginCode = authEventBus.emailLoginCodes.first() + val loginCode = externalEventBus.emailLoginCodes.first() if (loginCode.isNotBlank()) { val tokens = authApi.magicLinkLogin(loginCode) tokens.accessToken?.let { accessToken -> @@ -101,6 +101,6 @@ class MagicLinkLoginViewModel @Inject constructor( } fun clearMagicLinkLogin() { - authEventBus.onEmailLoginLink("") + externalEventBus.onEmailLoginLink("") } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt index 91d4fb4f1..496ea3af5 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/PasswordRecoverViewModel.kt @@ -7,7 +7,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator -import com.crisiscleanup.core.common.event.AuthEventBus +import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.log.AppLogger import com.crisiscleanup.core.common.log.CrisisCleanupLoggers import com.crisiscleanup.core.common.log.Logger @@ -31,7 +31,7 @@ class PasswordRecoverViewModel @Inject constructor( private val accountUpdateRepository: AccountUpdateRepository, private val inputValidator: InputValidator, private val translator: KeyResourceTranslator, - private val authEventBus: AuthEventBus, + private val externalEventBus: ExternalEventBus, @Logger(CrisisCleanupLoggers.Account) private val logger: AppLogger, ) : ViewModel() { val emailAddress = MutableStateFlow(null) @@ -43,7 +43,7 @@ class PasswordRecoverViewModel @Inject constructor( val resetPasswordErrorMessage = MutableStateFlow("") val resetPasswordConfirmErrorMessage = MutableStateFlow("") - val resetPasswordToken = authEventBus.resetPasswords + val resetPasswordToken = externalEventBus.resetPasswords private val isInitiatingPasswordReset = MutableStateFlow(false) private val isInitiatingMagicLink = MutableStateFlow(false) @@ -196,6 +196,6 @@ class PasswordRecoverViewModel @Inject constructor( } fun clearResetPassword() { - authEventBus.onResetPassword("") + externalEventBus.onResetPassword("") } } diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt index 92837eb3c..20c566a71 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt @@ -1,12 +1,37 @@ package com.crisiscleanup.feature.organizationmanage import androidx.lifecycle.ViewModel +import com.crisiscleanup.core.common.AppSettingsProvider +import com.crisiscleanup.core.common.InputValidator +import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.QrCodeGenerator +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Onboarding +import com.crisiscleanup.core.common.log.Logger +import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO +import com.crisiscleanup.core.common.network.Dispatcher +import com.crisiscleanup.core.data.IncidentSelectManager +import com.crisiscleanup.core.data.repository.AccountDataRepository +import com.crisiscleanup.core.data.repository.OrgVolunteerRepository +import com.crisiscleanup.core.data.repository.OrganizationsRepository import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineDispatcher import javax.inject.Inject @HiltViewModel class InviteTeammateViewModel @Inject constructor( - + settingsProvider: AppSettingsProvider, + accountDataRepository: AccountDataRepository, + organizationsRepository: OrganizationsRepository, + orgVolunteerRepository: OrgVolunteerRepository, + inputValidator: InputValidator, + qrCodeGenerator: QrCodeGenerator, + incidentSelectManager: IncidentSelectManager, + translator: KeyResourceTranslator, + @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + @Logger(Onboarding) private val logger: AppLogger, ) : ViewModel() { + private val inviteUrl = "${settingsProvider.baseUrl}/mobile_app_user_invite" + } \ No newline at end of file From 82ec8a267e013f0ead3f09fc8481c97728cab9fb Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 19 Dec 2023 15:26:52 -0500 Subject: [PATCH 6/7] Onboard new user to organization or new organization --- app/build.gradle.kts | 4 +- .../com/crisiscleanup/ZxingQrCodeGenerator.kt | 36 + .../java/com/crisiscleanup/di/AppModule.kt | 4 + .../navigation/CrisisCleanupNavHost.kt | 27 +- .../CrisisCleanupInterceptorProvider.kt | 2 +- .../apps/nowinandroid/KotlinAndroid.kt | 10 +- .../core/appnav/RouteConstant.kt | 1 + core/common/build.gradle.kts | 1 - .../core/common/AppSettingsProvider.kt | 2 +- .../core/common/QrCodeGenerator.kt | 14 +- .../core/common/di/ApplicationModule.kt | 5 - .../core/common/event/ExternalEventBus.kt | 2 +- .../crisiscleanup/core/data/di/DataModule.kt | 5 + .../data/repository/OrgVolunteerRepository.kt | 79 +- .../repository/OrganizationsRepository.kt | 28 + .../database/dao/IncidentOrganizationDao.kt | 12 + .../core/database/dao/fts/OrganizationFts.kt | 12 + .../designsystem/component/ContainerView.kt | 6 +- .../component/CrisisCleanupLogoRow.kt | 59 ++ .../core/designsystem/component/TextField.kt | 15 + .../designsystem/icon/CrisisCleanupIcons.kt | 3 + .../core/designsystem/theme/Color.kt | 2 + .../worker_wheelbarrow_world_background.png | Bin .../core/domain/LoadIncidentDataUseCase.kt | 4 +- .../core/model/data/InvitationRequest.kt | 2 +- .../core/model/data/JoinOrgInvite.kt | 2 +- .../core/model/data/OrgUserInviteInfo.kt | 2 +- .../core/network/CrisisCleanupAccountApi.kt | 16 + .../core/network/CrisisCleanupAuthApi.kt | 28 + .../network/CrisisCleanupNetworkDataSource.kt | 36 +- .../core/network/CrisisCleanupRegisterApi.kt | 34 + .../core/network/di/NetworkModule.kt | 5 + .../network/model/NetworkInvitationInfo.kt | 35 + .../network/model/NetworkInvitationRequest.kt | 92 +++ .../core/network/model/NetworkOrganization.kt | 33 + .../model/NetworkPersistentInvitation.kt | 39 + .../core/network/retrofit/DataApiClient.kt | 9 + .../network/retrofit/RegisterApiClient.kt | 261 +++++++ feature/authentication/build.gradle.kts | 1 + .../authentication/LoginWithPhoneViewModel.kt | 35 +- .../authentication/MagicLinkLoginViewModel.kt | 7 +- .../authentication/ui/AuthComposables.kt | 43 +- .../authentication/ui/LoginWithEmailScreen.kt | 1 + .../authentication/ui/LoginWithPhoneScreen.kt | 21 +- .../authentication/ui/MagicLinkLoginScreen.kt | 2 +- .../authentication/ui/RootAuthScreen.kt | 26 +- .../caseeditor/ui/EditExistingCaseScreen.kt | 2 +- .../feature/cases/CasesTableViewDataLoader.kt | 2 +- .../crisiscleanup/feature/menu/MenuScreen.kt | 19 +- .../feature/menu/navigation/MenuNavigation.kt | 2 + feature/organizationmanage/build.gradle.kts | 2 + .../InviteTeammateViewModel.kt | 685 ++++++++++++++++- .../di/OrganizationManageModule.kt | 10 - .../navigation/InviteTeammateNavigation.kt | 18 + .../ui/InviteTeammateScreen.kt | 690 +++++++++++++++++- gradle/libs.versions.toml | 6 +- lint/build.gradle.kts | 7 +- 57 files changed, 2295 insertions(+), 211 deletions(-) create mode 100644 app/src/main/java/com/crisiscleanup/ZxingQrCodeGenerator.kt create mode 100644 core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/CrisisCleanupLogoRow.kt rename {feature/authentication => core/designsystem}/src/main/res/drawable-hdpi/worker_wheelbarrow_world_background.png (100%) create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAuthApi.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationRequest.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPersistentInvitation.kt create mode 100644 core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt delete mode 100644 feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8319db813..50ac0c5e2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 170 + val buildVersion = 172 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" @@ -112,6 +112,7 @@ dependencies { implementation(project(":feature:dashboard")) implementation(project(":feature:menu")) implementation(project(":feature:mediamanage")) + implementation(project(":feature:organizationmanage")) implementation(project(":feature:syncinsights")) implementation(project(":feature:team")) implementation(project(":feature:userfeedback")) @@ -157,6 +158,7 @@ dependencies { implementation(libs.androidx.window.manager) implementation(libs.androidx.profileinstaller) implementation(libs.playservices.location) + implementation(libs.zxing) implementation(libs.coil.kt) diff --git a/app/src/main/java/com/crisiscleanup/ZxingQrCodeGenerator.kt b/app/src/main/java/com/crisiscleanup/ZxingQrCodeGenerator.kt new file mode 100644 index 000000000..0962fd984 --- /dev/null +++ b/app/src/main/java/com/crisiscleanup/ZxingQrCodeGenerator.kt @@ -0,0 +1,36 @@ +package com.crisiscleanup + +import android.graphics.Bitmap +import android.graphics.Color +import com.crisiscleanup.core.common.QrCodeGenerator +import com.google.zxing.BarcodeFormat +import com.google.zxing.EncodeHintType +import com.google.zxing.qrcode.QRCodeWriter +import javax.inject.Inject + +class ZxingQrCodeGenerator @Inject constructor() : QrCodeGenerator { + override fun generate(payload: String, size: Int): Bitmap? { + if (payload.isBlank()) { + return null + } + + val writer = QRCodeWriter() + // Removing margin in image should add padding around image display + val hints = mapOf( + EncodeHintType.MARGIN to 0, + ) + val bitMatrix = writer.encode(payload, BarcodeFormat.QR_CODE, size, size, hints) + val width = bitMatrix.width + val height = bitMatrix.height + val pixels = IntArray(width * height) + for (h in 0 until height) { + val offset = h * width + for (w in 0 until width) { + pixels[offset + w] = if (bitMatrix.get(w, h)) Color.BLACK else Color.TRANSPARENT + } + } + val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888) + bitmap.setPixels(pixels, 0, width, 0, 0, width, height) + return bitmap + } +} diff --git a/app/src/main/java/com/crisiscleanup/di/AppModule.kt b/app/src/main/java/com/crisiscleanup/di/AppModule.kt index d21307a97..88d1f06a0 100644 --- a/app/src/main/java/com/crisiscleanup/di/AppModule.kt +++ b/app/src/main/java/com/crisiscleanup/di/AppModule.kt @@ -7,6 +7,7 @@ import com.crisiscleanup.AndroidLocationProvider import com.crisiscleanup.AndroidPermissionManager import com.crisiscleanup.AppVisualAlertManager import com.crisiscleanup.CrisisCleanupAppEnv +import com.crisiscleanup.ZxingQrCodeGenerator import com.crisiscleanup.core.appheader.AppHeaderUiState import com.crisiscleanup.core.common.* import com.crisiscleanup.core.common.log.TagLogger @@ -65,6 +66,9 @@ interface AppModule { @Binds fun bindsVisualAlertManager(manager: AppVisualAlertManager): VisualAlertManager + + @Binds + fun bindsQrCodeGenerator(generator: ZxingQrCodeGenerator): QrCodeGenerator } @Module diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt index 97bcdf4b8..d0e637c66 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupNavHost.kt @@ -32,6 +32,8 @@ import com.crisiscleanup.feature.cases.ui.CasesAction import com.crisiscleanup.feature.dashboard.navigation.dashboardScreen import com.crisiscleanup.feature.mediamanage.navigation.viewSingleImageScreen import com.crisiscleanup.feature.menu.navigation.menuScreen +import com.crisiscleanup.feature.organizationmanage.navigation.inviteTeammateScreen +import com.crisiscleanup.feature.organizationmanage.navigation.navigateToInviteTeammate import com.crisiscleanup.feature.syncinsights.navigation.navigateToSyncInsights import com.crisiscleanup.feature.syncinsights.navigation.syncInsightsScreen import com.crisiscleanup.feature.team.navigation.teamScreen @@ -88,21 +90,14 @@ fun CrisisCleanupNavHost( { ids: ExistingWorksiteIdentifier -> navController.rerouteToCaseChange(ids) } } - val openFilterCases = remember(navController) { - { navController.navigateToCasesFilter() } - } + val openFilterCases = remember(navController) { { navController.navigateToCasesFilter() } } - val openUserFeedback = remember(navController) { - { - navController.navigateToUserFeedback() - } - } + val openInviteTeammate = + remember(navController) { { navController.navigateToInviteTeammate() } } - val openSyncLogs = remember(navController) { - { - navController.navigateToSyncInsights() - } - } + val openUserFeedback = remember(navController) { { navController.navigateToUserFeedback() } } + + val openSyncLogs = remember(navController) { { navController.navigateToSyncInsights() } } val navToCaseAddFlagNonEditing = remember(navController) { { navController.navigateToCaseAddFlag(false) } } @@ -138,10 +133,12 @@ fun CrisisCleanupNavHost( dashboardScreen() teamScreen() menuScreen( - openUserFeedback, - openSyncLogs, + openInviteTeammate = openInviteTeammate, + openUserFeedback = openUserFeedback, + openSyncLogs = openSyncLogs, ) viewSingleImageScreen(onBack) + inviteTeammateScreen(onBack) userFeedbackScreen(onBack) syncInsightsScreen(viewCase) } diff --git a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt index 2c9d67d88..8ab67f2b0 100644 --- a/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt +++ b/app/src/main/java/com/crisiscleanup/network/CrisisCleanupInterceptorProvider.kt @@ -180,7 +180,7 @@ class CrisisCleanupInterceptorProvider @Inject constructor( var response: Response = chain.proceed(request) getHeaderKey(request, RequestHeaderKey.WrapResponse)?.let { key -> - if (response.code == 200) { + if (response.code in 200..299) { // TODO Write tests. Including expired tokens where tokens are used. // Would be more elegant to deserialize, make new, and re-serialize. // Data structure is simple so text operations are sufficient. diff --git a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt index 08d3042ec..0eb30b788 100644 --- a/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt +++ b/build-logic/convention/src/main/kotlin/com/google/samples/apps/nowinandroid/KotlinAndroid.kt @@ -44,8 +44,8 @@ internal fun Project.configureKotlinAndroid( compileOptions { // Up to Java 11 APIs are available through desugaring // https://developer.android.com/studio/write/java11-minimal-support-table - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 isCoreLibraryDesugaringEnabled = true } } @@ -64,8 +64,8 @@ internal fun Project.configureKotlinJvm() { extensions.configure { // Up to Java 11 APIs are available through desugaring // https://developer.android.com/studio/write/java11-minimal-support-table - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } configureKotlin() @@ -79,7 +79,7 @@ private fun Project.configureKotlin() { tasks.withType().configureEach { kotlinOptions { // Set JVM target to 11 - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() // Treat all Kotlin warnings as errors (disabled by default) // Override by setting warningsAsErrors=true in your ~/.gradle/gradle.properties val warningsAsErrors: String? by project diff --git a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt index 784536c0a..722fdd9ce 100644 --- a/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt +++ b/core/appnav/src/main/java/com/crisiscleanup/core/appnav/RouteConstant.kt @@ -19,6 +19,7 @@ object RouteConstant { const val teamRoute = "team_route" val topLevelRoutes = setOf(casesRoute, menuRoute) + const val inviteTeammateRoute = "invite_teammate" const val userFeedbackRoute = "user_feedback_route" const val syncInsightsRoute = "sync_insights_route" diff --git a/core/common/build.gradle.kts b/core/common/build.gradle.kts index 98597b838..348ce34f4 100644 --- a/core/common/build.gradle.kts +++ b/core/common/build.gradle.kts @@ -21,6 +21,5 @@ dependencies { implementation(libs.kotlinx.coroutines.android) implementation(libs.kotlinx.datetime) implementation(libs.timeago) - implementation(libs.qrcode.kotlin) testImplementation(project(":core:testing")) } \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt b/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt index 3d56ba618..5c69056e2 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/AppSettingsProvider.kt @@ -23,4 +23,4 @@ class SecretsAppSettingsProvider @Inject constructor() : AppSettingsProvider { get() = BuildConfig.DEBUG_EMAIL_ADDRESS override val debugPassword: String get() = BuildConfig.DEBUG_ACCOUNT_PASSWORD -} \ No newline at end of file +} diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt b/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt index b7d07de5b..11ac2d664 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/QrCodeGenerator.kt @@ -1,19 +1,7 @@ package com.crisiscleanup.core.common import android.graphics.Bitmap -import android.graphics.BitmapFactory -import qrcode.QRCode interface QrCodeGenerator { - fun generate(payload: String): Bitmap? + fun generate(payload: String, size: Int = 512): Bitmap? } - -class QrCodeKotlinGenerator : QrCodeGenerator { - override fun generate(payload: String): Bitmap? { - val code = QRCode.ofSquares() - .build(payload) - - val pngBytes = code.renderToBytes() - return BitmapFactory.decodeByteArray(pngBytes, 0, pngBytes.size) - } -} \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt index 4e688df27..7127fda04 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/di/ApplicationModule.kt @@ -54,9 +54,4 @@ interface ApplicationModule { fun bindsExternalEventBus( bus: CrisisCleanupExternalEventBus, ): ExternalEventBus - - @Binds - fun bindsQrCodeGenerator( - generator: QrCodeKotlinGenerator, - ): QrCodeGenerator } diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt b/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt index fce500410..9d27e1487 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/event/ExternalEventBus.kt @@ -92,4 +92,4 @@ class CrisisCleanupExternalEventBus @Inject constructor( data class UserPersistentInvite( val inviterUserId: Long, val inviteToken: String, -) \ No newline at end of file +) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt b/core/data/src/main/java/com/crisiscleanup/core/data/di/DataModule.kt index b5f0119ce..48f25a8a3 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 @@ -135,6 +135,11 @@ interface DataModule { fun bindsEndOfLifeRepository( repository: AppEndOfLifeRepository, ): EndOfLifeRepository + + @Binds + fun bindsOrgVolunteerRepository( + repository: CrisisCleanupOrgVolunteerRepository, + ): OrgVolunteerRepository } @Module diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt index ef44fb7eb..6320baccc 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrgVolunteerRepository.kt @@ -1,12 +1,17 @@ package com.crisiscleanup.core.data.repository import com.crisiscleanup.core.common.event.UserPersistentInvite +import com.crisiscleanup.core.common.log.AppLogger +import com.crisiscleanup.core.common.log.CrisisCleanupLoggers.Onboarding +import com.crisiscleanup.core.common.log.Logger import com.crisiscleanup.core.model.data.CodeInviteAccept import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo import com.crisiscleanup.core.model.data.InvitationRequest import com.crisiscleanup.core.model.data.JoinOrgInvite import com.crisiscleanup.core.model.data.JoinOrgResult import com.crisiscleanup.core.model.data.OrgUserInviteInfo +import com.crisiscleanup.core.network.CrisisCleanupRegisterApi +import kotlinx.datetime.Instant import javax.inject.Inject interface OrgVolunteerRepository { @@ -25,46 +30,102 @@ interface OrgVolunteerRepository { ): Boolean } -class CrisisCleanupOrgVolunteerRepository @Inject constructor() : OrgVolunteerRepository { +class CrisisCleanupOrgVolunteerRepository @Inject constructor( + private val registerApi: CrisisCleanupRegisterApi, + @Logger(Onboarding) private val logger: AppLogger, +) : OrgVolunteerRepository { override suspend fun requestInvitation(invite: InvitationRequest): InvitationRequestResult? { - TODO("Not yet implemented") + try { + // TODO Handle cases where an invite was already sent to the user from the org + val result = registerApi.registerOrgVolunteer(invite) + return InvitationRequestResult( + organizationName = result.requestedOrganization, + organizationRecipient = result.requestedTo, + ) + } catch (e: Exception) { + logger.logException(e) + } + + return null } override suspend fun getInvitationInfo(inviteCode: String): OrgUserInviteInfo? { - TODO("Not yet implemented") + try { + return registerApi.getInvitationInfo(inviteCode) + } catch (e: Exception) { + logger.logException(e) + } + return null } override suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? { - TODO("Not yet implemented") + try { + return registerApi.getInvitationInfo(invite) + } catch (e: Exception) { + logger.logException(e) + } + return null } override suspend fun acceptInvitation(invite: CodeInviteAccept): JoinOrgResult { - TODO("Not yet implemented") + try { + // TODO Handle cases where an invite was already sent to the user from the org + return registerApi.acceptOrgInvitation(invite) + } catch (e: Exception) { + logger.logException(e) + } + return JoinOrgResult.Unknown } override suspend fun getOrganizationInvite( organizationId: Long, inviterUserId: Long, ): JoinOrgInvite { - TODO("Not yet implemented") + try { + val invite = + registerApi.createPersistentInvitation(organizationId, userId = inviterUserId) + return JoinOrgInvite( + invite.token, + invite.objectId, + invite.expiresAt, + ) + } catch (e: Exception) { + logger.logException(e) + } + return JoinOrgInvite("", 0, Instant.fromEpochSeconds(0)) } override suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult { - TODO("Not yet implemented") + try { + return registerApi.acceptPersistentInvitation(invite) + } catch (e: Exception) { + logger.logException(e) + } + return JoinOrgResult.Unknown } override suspend fun inviteToOrganization( emailAddress: String, organizationId: Long?, ): Boolean { - TODO("Not yet implemented") + try { + return registerApi.inviteToOrganization(emailAddress, organizationId) + } catch (e: Exception) { + logger.logException(e) + } + return false } override suspend fun createOrganization( referer: String, invite: IncidentOrganizationInviteInfo, ): Boolean { - TODO("Not yet implemented") + try { + return registerApi.registerOrganization(referer, invite) + } catch (e: Exception) { + logger.logException(e) + } + return false } } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt index 23b2eea7e..4fb6ffb5b 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OrganizationsRepository.kt @@ -10,6 +10,8 @@ import com.crisiscleanup.core.database.dao.IncidentOrganizationDaoPlus import com.crisiscleanup.core.database.dao.LocationDao import com.crisiscleanup.core.database.dao.LocationDaoPlus import com.crisiscleanup.core.database.dao.fts.getMatchingOrganizations +import com.crisiscleanup.core.database.dao.fts.streamMatchingOrganizations +import com.crisiscleanup.core.database.model.IncidentOrganizationEntity import com.crisiscleanup.core.database.model.PopulatedIncidentOrganization import com.crisiscleanup.core.database.model.PopulatedLocation import com.crisiscleanup.core.database.model.asExternalModel @@ -20,6 +22,7 @@ import com.crisiscleanup.core.model.data.OrganizationIdName import com.crisiscleanup.core.model.data.OrganizationLocationAreaBounds import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkIncidentOrganization +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -46,6 +49,10 @@ interface OrganizationsRepository { fun streamPrimarySecondaryAreas(organizationId: Long): Flow suspend fun getMatchingOrganizations(q: String): List + + suspend fun streamMatchingOrganizations(q: String): Flow> + + suspend fun searchOrganizations(q: String) } class OfflineFirstOrganizationsRepository @Inject constructor( @@ -160,4 +167,25 @@ class OfflineFirstOrganizationsRepository @Inject constructor( override suspend fun getMatchingOrganizations(q: String) = incidentOrganizationDaoPlus.getMatchingOrganizations(q) + + override suspend fun streamMatchingOrganizations(q: String) = + incidentOrganizationDaoPlus.streamMatchingOrganizations(q) + + override suspend fun searchOrganizations(q: String) = coroutineScope { + val organizations = networkDataSource.searchOrganizations(q) + val organizationRecords = organizations.map { + IncidentOrganizationEntity( + id = it.id, + name = it.name, + primaryLocation = null, + secondaryLocation = null, + ) + } + + try { + incidentOrganizationDaoPlus.saveOrganizations(organizationRecords, emptyList()) + } catch (e: Exception) { + logger.logException(e) + } + } } diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt index cfbcb7347..7363a798f 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/IncidentOrganizationDao.kt @@ -90,6 +90,18 @@ interface IncidentOrganizationDao { ) fun matchOrganizationName(query: String): List + @Transaction + @Query( + """ + SELECT io.id, f.name, + matchinfo(incident_organization_fts, 'pcnalx') AS match_info + FROM incident_organization_fts f + INNER JOIN incident_organizations io ON f.docid=io.id + WHERE incident_organization_fts MATCH :query + """, + ) + fun streamMatchingOrganizations(query: String): Flow> + @Transaction @Query("SELECT id FROM incident_organizations WHERE id=:id") fun findOrganization(id: Long): Long? diff --git a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt index e5ae702fc..5c8e12d07 100644 --- a/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt +++ b/core/database/src/main/java/com/crisiscleanup/core/database/dao/fts/OrganizationFts.kt @@ -15,6 +15,8 @@ import com.crisiscleanup.core.database.util.okapiBm25Score import com.crisiscleanup.core.model.data.OrganizationIdName import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapLatest @Entity( "incident_organization_fts", @@ -85,3 +87,13 @@ suspend fun IncidentOrganizationDaoPlus.getMatchingOrganizations(q: String): Lis .map(PopulatedOrganizationIdNameMatchInfo::idName) } } + +suspend fun IncidentOrganizationDaoPlus.streamMatchingOrganizations(q: String): Flow> = + coroutineScope { + db.incidentOrganizationDao() + .streamMatchingOrganizations(q.ftsSanitize.ftsGlobEnds) + .mapLatest { matching -> + matching.sortedByDescending { it.sortScore } + .map(PopulatedOrganizationIdNameMatchInfo::idName) + } + } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ContainerView.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ContainerView.kt index b354e86b4..18c136bed 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ContainerView.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ContainerView.kt @@ -7,14 +7,16 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.theme.unfocusedBorderColor fun Modifier.roundedOutline( width: Dp = 1.dp, radius: Dp = 4.dp, + // TODO Common color + color: Color = unfocusedBorderColor, ) = drawBehind { drawRoundRect( - // TODO Common color - color = Color.Gray, + color = color, style = Stroke(width = width.toPx()), cornerRadius = CornerRadius(radius.toPx()), ) diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/CrisisCleanupLogoRow.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/CrisisCleanupLogoRow.kt new file mode 100644 index 000000000..a0d5f50f6 --- /dev/null +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/CrisisCleanupLogoRow.kt @@ -0,0 +1,59 @@ +package com.crisiscleanup.core.designsystem.component + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.crisiscleanup.core.designsystem.R +import com.crisiscleanup.core.designsystem.theme.fillWidthPadded +import com.crisiscleanup.core.common.R as commonR + +@Composable +fun CrisisCleanupLogoRow( + hideHeaderText: Boolean = false, +) { + // TODO Adjust to other screen sizes as necessary + Box(Modifier.padding(top = 16.dp, start = 8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Start, + ) { + Image( + painterResource(R.drawable.worker_wheelbarrow_world_background), + modifier = Modifier + .testTag("ccuBackground") + .padding(top = 32.dp) + .size(width = 480.dp, height = 240.dp) + .offset(x = 64.dp), + contentScale = ContentScale.FillHeight, + contentDescription = null, + ) + } + if (!hideHeaderText) { + Row( + modifier = fillWidthPadded, + horizontalArrangement = Arrangement.Start, + ) { + Image( + modifier = Modifier + .testTag("ccuLogo") + .sizeIn(maxWidth = 160.dp), + painter = painterResource(commonR.drawable.crisis_cleanup_logo), + contentDescription = stringResource(commonR.string.crisis_cleanup), + ) + } + } + } +} diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt index 4d34b2989..bc7d23056 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TextField.kt @@ -48,6 +48,7 @@ fun OutlinedSingleLineTextField( onNext: (() -> Unit)? = null, onEnter: (() -> Unit)? = null, onSearch: (() -> Unit)? = null, + leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, imeAction: ImeAction = ImeAction.Next, nextDirection: FocusDirection = FocusDirection.Down, @@ -67,6 +68,7 @@ fun OutlinedSingleLineTextField( onNext, onEnter, onSearch, + leadingIcon, trailingIcon, imeAction, nextDirection, @@ -90,6 +92,7 @@ fun SingleLineTextField( onNext: (() -> Unit)? = null, onEnter: (() -> Unit)? = null, onSearch: (() -> Unit)? = null, + leadingIcon: (@Composable () -> Unit)? = null, trailingIcon: (@Composable () -> Unit)? = null, imeAction: ImeAction = ImeAction.Next, nextDirection: FocusDirection = FocusDirection.Down, @@ -122,6 +125,12 @@ fun SingleLineTextField( } else { { Text(labelText) } } + val leadingIconContent: (@Composable (() -> Unit)?) = + if (leadingIcon == null) { + null + } else { + { leadingIcon() } + } val trailingIconContent: (@Composable (() -> Unit)?) = if (value.isEmpty() || trailingIcon == null) { null @@ -148,6 +157,7 @@ fun SingleLineTextField( enabled = enabled, isError = isError, visualTransformation = visualTransformation, + leadingIcon = leadingIconContent, trailingIcon = trailingIconContent, readOnly = readOnly, placeholder = placeholderContent, @@ -166,6 +176,7 @@ fun SingleLineTextField( enabled = enabled, isError = isError, visualTransformation = visualTransformation, + leadingIcon = leadingIconContent, trailingIcon = trailingIconContent, readOnly = readOnly, placeholder = placeholderContent, @@ -197,6 +208,7 @@ fun OutlinedClearableTextField( isError: Boolean, @StringRes labelResId: Int = 0, label: String = "", + leadingIcon: (@Composable () -> Unit)? = null, hasFocus: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, keyboardCapitalization: KeyboardCapitalization = KeyboardCapitalization.None, @@ -212,6 +224,7 @@ fun OutlinedClearableTextField( enabled, isError, label, + leadingIcon, hasFocus, keyboardType, keyboardCapitalization, @@ -232,6 +245,7 @@ fun ClearableTextField( enabled: Boolean, isError: Boolean, label: String = "", + leadingIcon: (@Composable () -> Unit)? = null, hasFocus: Boolean = false, keyboardType: KeyboardType = KeyboardType.Text, keyboardCapitalization: KeyboardCapitalization = KeyboardCapitalization.None, @@ -273,6 +287,7 @@ fun ClearableTextField( onNext = onNext, onEnter = onEnter, onSearch = onSearch, + leadingIcon = leadingIcon, trailingIcon = trailingIcon, imeAction = imeAction, drawOutline = drawOutline, diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt index c648c6bf1..18b562af2 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/icon/CrisisCleanupIcons.kt @@ -7,6 +7,7 @@ import androidx.compose.material.icons.filled.ArrowBackIosNew import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarMonth import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Cloud @@ -51,6 +52,7 @@ object CrisisCleanupIcons { val CaretUp = icons.KeyboardArrowUp val Cases = R.drawable.ic_cases val Check = icons.Check + val CheckCircle = icons.CheckCircle val Clear = icons.Clear val CloudSync = icons.CloudSync val Cloud = icons.Cloud @@ -59,6 +61,7 @@ object CrisisCleanupIcons { val Delete = icons.Delete val Directions = icons.Directions val Edit = icons.Edit + val ExpandAll = icons.UnfoldMore val ExpandLess = icons.ExpandLess val ExpandMore = icons.ExpandMore val Help = icons.HelpOutline 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 b923cd7b8..e78b2bd51 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 statusClosedColor = Color(statusDuplicateClaimedColorCode) + val visitedCaseMarkerColorCode = 0xFF681da8 val avatarAttentionColor = primaryRedColor diff --git a/feature/authentication/src/main/res/drawable-hdpi/worker_wheelbarrow_world_background.png b/core/designsystem/src/main/res/drawable-hdpi/worker_wheelbarrow_world_background.png similarity index 100% rename from feature/authentication/src/main/res/drawable-hdpi/worker_wheelbarrow_world_background.png rename to core/designsystem/src/main/res/drawable-hdpi/worker_wheelbarrow_world_background.png diff --git a/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadIncidentDataUseCase.kt b/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadIncidentDataUseCase.kt index cd2fa1b4f..b0738d147 100644 --- a/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadIncidentDataUseCase.kt +++ b/core/domain/src/main/java/com/crisiscleanup/core/domain/LoadIncidentDataUseCase.kt @@ -55,13 +55,13 @@ class LoadIncidentDataUseCase @Inject constructor( } sealed interface IncidentsData { - object Loading : IncidentsData + data object Loading : IncidentsData data class Incidents( val incidents: List, ) : IncidentsData - object Empty : IncidentsData + data object Empty : IncidentsData } @Module diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt index b782ae380..cffa8298e 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/InvitationRequest.kt @@ -31,4 +31,4 @@ data class CodeInviteAccept( val languageId: Long, val invitationCode: String, -) \ No newline at end of file +) diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt index 99866a194..acb66296b 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/JoinOrgInvite.kt @@ -16,5 +16,5 @@ enum class JoinOrgResult { // Already joined Redundant, PendingAdditionalAction, - Unknown + Unknown, } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt index e17ac3c52..1cfd1b41f 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/OrgUserInviteInfo.kt @@ -13,7 +13,7 @@ data class OrgUserInviteInfo( val isExpiredInvite: Boolean, ) -internal val ExpiredNetworkOrgInvite = OrgUserInviteInfo( +val ExpiredNetworkOrgInvite = OrgUserInviteInfo( displayName = "", inviterEmail = "", inviterAvatarUrl = null, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt new file mode 100644 index 000000000..5b73dfebb --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAccountApi.kt @@ -0,0 +1,16 @@ +package com.crisiscleanup.core.network + +import com.crisiscleanup.core.network.model.InitiatePasswordResetResult + +interface CrisisCleanupAccountApi { + suspend fun initiateMagicLink(emailAddress: String): Boolean + + suspend fun initiatePhoneLogin(phoneNumber: String): Boolean + + suspend fun initiatePasswordReset(emailAddress: String): InitiatePasswordResetResult + + suspend fun changePassword( + password: String, + token: String, + ): Boolean +} diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAuthApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAuthApi.kt new file mode 100644 index 000000000..1628e0480 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupAuthApi.kt @@ -0,0 +1,28 @@ +package com.crisiscleanup.core.network + +import com.crisiscleanup.core.network.model.NetworkAuthResult +import com.crisiscleanup.core.network.model.NetworkCodeAuthResult +import com.crisiscleanup.core.network.model.NetworkOauthResult +import com.crisiscleanup.core.network.model.NetworkPhoneOneTimePasswordResult + +interface CrisisCleanupAuthApi { + suspend fun login(email: String, password: String): NetworkAuthResult + + suspend fun oauthLogin(email: String, password: String): NetworkOauthResult + + suspend fun magicLinkLogin(token: String): NetworkCodeAuthResult + + suspend fun verifyPhoneCode( + phoneNumber: String, + code: String, + ): NetworkPhoneOneTimePasswordResult? + + suspend fun oneTimePasswordLogin( + accountId: Long, + oneTimePasswordId: Long, + ): NetworkCodeAuthResult? + + suspend fun refreshTokens(refreshToken: String): NetworkOauthResult? + + suspend fun logout() +} 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 fab8b8f76..ff4bf0de3 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 @@ -1,19 +1,15 @@ package com.crisiscleanup.core.network -import com.crisiscleanup.core.network.model.InitiatePasswordResetResult -import com.crisiscleanup.core.network.model.NetworkAuthResult import com.crisiscleanup.core.network.model.NetworkCaseHistoryEvent -import com.crisiscleanup.core.network.model.NetworkCodeAuthResult import com.crisiscleanup.core.network.model.NetworkCountResult import com.crisiscleanup.core.network.model.NetworkIncident import com.crisiscleanup.core.network.model.NetworkIncidentOrganization import com.crisiscleanup.core.network.model.NetworkLanguageDescription import com.crisiscleanup.core.network.model.NetworkLanguageTranslation import com.crisiscleanup.core.network.model.NetworkLocation -import com.crisiscleanup.core.network.model.NetworkOauthResult +import com.crisiscleanup.core.network.model.NetworkOrganizationShort import com.crisiscleanup.core.network.model.NetworkOrganizationsResult import com.crisiscleanup.core.network.model.NetworkPersonContact -import com.crisiscleanup.core.network.model.NetworkPhoneOneTimePasswordResult import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkWorkTypeRequest import com.crisiscleanup.core.network.model.NetworkWorkTypeStatusResult @@ -24,34 +20,6 @@ import com.crisiscleanup.core.network.model.NetworkWorksitePage import com.crisiscleanup.core.network.model.NetworkWorksiteShort import kotlinx.datetime.Instant -interface CrisisCleanupAuthApi { - suspend fun login(email: String, password: String): NetworkAuthResult - suspend fun oauthLogin(email: String, password: String): NetworkOauthResult - suspend fun magicLinkLogin(token: String): NetworkCodeAuthResult - suspend fun verifyPhoneCode( - phoneNumber: String, - code: String, - ): NetworkPhoneOneTimePasswordResult? - - suspend fun oneTimePasswordLogin( - accountId: Long, - oneTimePasswordId: Long, - ): NetworkCodeAuthResult? - - suspend fun refreshTokens(refreshToken: String): NetworkOauthResult? - suspend fun logout() -} - -interface CrisisCleanupAccountApi { - suspend fun initiateMagicLink(emailAddress: String): Boolean - suspend fun initiatePhoneLogin(phoneNumber: String): Boolean - suspend fun initiatePasswordReset(emailAddress: String): InitiatePasswordResetResult - suspend fun changePassword( - password: String, - token: String, - ): Boolean -} - interface CrisisCleanupNetworkDataSource { suspend fun getProfilePic(): String? @@ -142,5 +110,7 @@ interface CrisisCleanupNetworkDataSource { suspend fun getUsers(ids: Collection): List + suspend fun searchOrganizations(q: String): List + suspend fun getProfile(accessToken: String): NetworkUserProfile? } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt new file mode 100644 index 000000000..fab484464 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/CrisisCleanupRegisterApi.kt @@ -0,0 +1,34 @@ +package com.crisiscleanup.core.network + +import com.crisiscleanup.core.common.event.UserPersistentInvite +import com.crisiscleanup.core.model.data.CodeInviteAccept +import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo +import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgUserInviteInfo +import com.crisiscleanup.core.network.model.NetworkAcceptedInvitationRequest +import com.crisiscleanup.core.network.model.NetworkPersistentInvitation + +interface CrisisCleanupRegisterApi { + suspend fun registerOrgVolunteer(invite: InvitationRequest): NetworkAcceptedInvitationRequest + + suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? + + suspend fun getInvitationInfo(inviteCode: String): OrgUserInviteInfo? + + suspend fun acceptOrgInvitation(invite: CodeInviteAccept): JoinOrgResult + + suspend fun createPersistentInvitation( + organizationId: Long, + userId: Long, + ): NetworkPersistentInvitation + + suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult + + suspend fun inviteToOrganization(emailAddress: String, organizationId: Long?): Boolean + + suspend fun registerOrganization( + referer: String, + invite: IncidentOrganizationInviteInfo, + ): Boolean +} 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 850ef4134..250e97f44 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 @@ -11,6 +11,7 @@ import com.crisiscleanup.core.network.BuildConfig import com.crisiscleanup.core.network.CrisisCleanupAccountApi import com.crisiscleanup.core.network.CrisisCleanupAuthApi import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource +import com.crisiscleanup.core.network.CrisisCleanupRegisterApi import com.crisiscleanup.core.network.CrisisCleanupWriteApi import com.crisiscleanup.core.network.RetrofitInterceptorProvider import com.crisiscleanup.core.network.appsupport.AppSupportApiClient @@ -19,6 +20,7 @@ import com.crisiscleanup.core.network.fake.FakeAssetManager import com.crisiscleanup.core.network.retrofit.AccountApiClient import com.crisiscleanup.core.network.retrofit.AuthApiClient import com.crisiscleanup.core.network.retrofit.DataApiClient +import com.crisiscleanup.core.network.retrofit.RegisterApiClient import com.crisiscleanup.core.network.retrofit.RequestHeaderKeysLookup import com.crisiscleanup.core.network.retrofit.RetrofitConfiguration import com.crisiscleanup.core.network.retrofit.RetrofitConfigurations @@ -54,6 +56,9 @@ interface NetworkInterfaceModule { @Binds fun bindsAccountApiClient(apiClient: AccountApiClient): CrisisCleanupAccountApi + @Binds + fun bindsRegisterApiClient(apiClient: RegisterApiClient): CrisisCleanupRegisterApi + @Binds fun bindsAppSupportApiClient(apiClient: AppSupportApiClient): AppSupportClient } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt new file mode 100644 index 000000000..78f32c675 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationInfo.kt @@ -0,0 +1,35 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkInvitationInfoResult( + val errors: List?, + val count: Int?, + val invite: NetworkInvitationInfo?, +) + +@Serializable +data class NetworkInvitationInfo( + @SerialName("invitee_email") + val inviteeEmail: String, + @SerialName("expires_at") + val expiresAt: Instant, + val organization: Long, + @SerialName("invited_by") + val inviter: NetworkInviterInfo, +) + +@Serializable +data class NetworkInviterInfo( + val id: Long, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val email: String, + @SerialName("mobile") + val phone: String, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationRequest.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationRequest.kt new file mode 100644 index 000000000..f4ce852cf --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkInvitationRequest.kt @@ -0,0 +1,92 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkInvitationRequest( + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val email: String, + val title: String, + val password1: String, + val password2: String, + val mobile: String, + @SerialName("requested_to") + val requestedTo: String, + @SerialName("primary_language") + val primaryLanguage: Long, +) + +@Serializable +data class NetworkAcceptedInvitationRequest( + val id: Long, + @SerialName("requested_to") + val requestedTo: String, + @SerialName("requested_to_organization") + val requestedOrganization: String, +) + +@Serializable +data class NetworkAcceptCodeInvite( + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val email: String, + val title: String, + val password: String, + val mobile: String, + @SerialName("invitation_token") + val invitationToken: String, + @SerialName("primary_language") + val primaryLanguage: Long, +) + +@Serializable +data class NetworkAcceptPersistentInvite( + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val email: String, + val title: String, + val password: String, + val mobile: String, + val token: String, +) + +@Serializable +data class NetworkAcceptedPersistentInvite( + val detail: String, +) + +@Serializable +data class NetworkOrganizationInvite( + @SerialName("invitee_email") + val inviteeEmail: String, + val organization: Long?, +) + +@Serializable +data class NetworkOrganizationInviteResult( + val errors: List?, + val invite: NetworkOrganizationInviteInfo?, +) + +@Serializable +data class NetworkOrganizationInviteInfo( + val id: Long, + @SerialName("invitee_email") + val inviteeEmail: String, + @SerialName("invitation_token") + val invitationToken: String, + @SerialName("expires_at") + val expiresAt: Instant, + val organization: Long, + @SerialName("created_at") + val createdAt: Instant, +) 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 2aa7c837f..22346f7fa 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 @@ -30,3 +30,36 @@ data class NetworkOrganizationShort( val id: Long, val name: String, ) + +@Serializable +data class NetworkOrganizationsSearchResult( + val errors: List?, + val count: Int?, + val results: List?, +) + +@Serializable +data class NetworkRegisterOrganizationResult( + val errors: List?, + val organization: NetworkOrganizationShort, +) + +@Serializable +data class NetworkOrganizationRegistration( + val name: String, + val referral: String, + val incident: Long, + val contact: NetworkOrganizationContact, +) + +@Serializable +data class NetworkOrganizationContact( + val email: String, + @SerialName("first_name") + val firstName: String, + @SerialName("last_name") + val lastName: String, + val mobile: String, + val title: String?, + val organization: Long?, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPersistentInvitation.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPersistentInvitation.kt new file mode 100644 index 000000000..cdff84abb --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkPersistentInvitation.kt @@ -0,0 +1,39 @@ +package com.crisiscleanup.core.network.model + +import kotlinx.datetime.Instant +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class NetworkCreateOrgInvitation( + val model: String, + @SerialName("created_by") + val createdBy: Long, + @SerialName("object_id") + val organizationId: Long, +) + +@Serializable +data class NetworkPersistentInvitationResult( + val errors: List?, + val invite: NetworkPersistentInvitation?, +) + +@Serializable +data class NetworkPersistentInvitation( + val id: Long, + val token: String, + val model: String, + @SerialName("object_id") + val objectId: Long, + @SerialName("requires_approval") + val requiresApproval: Boolean, + @SerialName("expires_at") + val expiresAt: Instant, + @SerialName("created_at") + val createdAt: Instant, + @SerialName("updated_at") + val updatedAt: Instant, + @SerialName("invalidated_at") + val invalidatedAt: Instant?, +) diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/DataApiClient.kt index b830896be..5897525e3 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 @@ -194,6 +194,12 @@ private interface DataSourceApi { ids: String, ): NetworkUsersResult + @TokenAuthenticationHeader + @GET("/organizations") + suspend fun searchOrganizations( + @Query("search") q: String, + ): NetworkOrganizationsSearchResult + @Headers("Cookie: ") @GET("/users/me") suspend fun getProfile( @@ -402,6 +408,9 @@ class DataApiClient @Inject constructor( it.results ?: emptyList() } + override suspend fun searchOrganizations(q: String) = + networkApi.searchOrganizations(q).results ?: emptyList() + override suspend fun getProfile(accessToken: String) = networkApi.getProfile("Bearer $accessToken") } diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt new file mode 100644 index 000000000..e09ffd2d8 --- /dev/null +++ b/core/network/src/main/java/com/crisiscleanup/core/network/retrofit/RegisterApiClient.kt @@ -0,0 +1,261 @@ +package com.crisiscleanup.core.network.retrofit + +import android.util.Log +import com.crisiscleanup.core.common.event.UserPersistentInvite +import com.crisiscleanup.core.model.data.CodeInviteAccept +import com.crisiscleanup.core.model.data.ExpiredNetworkOrgInvite +import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo +import com.crisiscleanup.core.model.data.InvitationRequest +import com.crisiscleanup.core.model.data.JoinOrgResult +import com.crisiscleanup.core.model.data.OrgUserInviteInfo +import com.crisiscleanup.core.network.CrisisCleanupRegisterApi +import com.crisiscleanup.core.network.model.NetworkAcceptCodeInvite +import com.crisiscleanup.core.network.model.NetworkAcceptPersistentInvite +import com.crisiscleanup.core.network.model.NetworkAcceptedInvitationRequest +import com.crisiscleanup.core.network.model.NetworkAcceptedPersistentInvite +import com.crisiscleanup.core.network.model.NetworkCreateOrgInvitation +import com.crisiscleanup.core.network.model.NetworkInvitationInfoResult +import com.crisiscleanup.core.network.model.NetworkInvitationRequest +import com.crisiscleanup.core.network.model.NetworkOrganizationContact +import com.crisiscleanup.core.network.model.NetworkOrganizationInvite +import com.crisiscleanup.core.network.model.NetworkOrganizationInviteResult +import com.crisiscleanup.core.network.model.NetworkOrganizationRegistration +import com.crisiscleanup.core.network.model.NetworkOrganizationShort +import com.crisiscleanup.core.network.model.NetworkPersistentInvitation +import com.crisiscleanup.core.network.model.NetworkPersistentInvitationResult +import com.crisiscleanup.core.network.model.NetworkRegisterOrganizationResult +import com.crisiscleanup.core.network.model.NetworkUser +import com.crisiscleanup.core.network.model.profilePictureUrl +import kotlinx.datetime.Clock +import retrofit2.Retrofit +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.POST +import retrofit2.http.Path +import java.net.URL +import javax.inject.Inject +import javax.inject.Singleton + +private interface RegisterApi { + @POST("invitation_requests") + suspend fun requestInvitation( + @Body invitationRequest: NetworkInvitationRequest, + ): NetworkAcceptedInvitationRequest + + @WrapResponseHeader("invite") + @GET("invitations") + suspend fun invitationInfo( + @Path("code") inviteCode: String, + ): NetworkInvitationInfoResult + + @WrapResponseHeader("invite") + @GET("persistent_invitations") + suspend fun persistentInvitationInfo( + @Path("code") inviteCode: String, + ): NetworkPersistentInvitationResult + + @GET("users/{user}") + suspend fun noAuthUser( + @Path("user") userId: Long, + ): NetworkUser + + @GET("organizations/{organization}") + suspend fun noAuthOrganization( + @Path("organization") organizationId: Long, + ): NetworkOrganizationShort + + @POST("invitations/accept") + suspend fun acceptInvitationFromCode( + @Body acceptInvite: NetworkAcceptCodeInvite, + ): NetworkAcceptedInvitationRequest + + @TokenAuthenticationHeader + @WrapResponseHeader("invite") + @POST("persistent_invitations") + suspend fun createPersistentInvitation( + @Body org: NetworkCreateOrgInvitation, + ): NetworkPersistentInvitationResult + + @POST("persistent_invitations/accept") + suspend fun acceptPersistentInvitation( + @Body acceptInvite: NetworkAcceptPersistentInvite, + ): NetworkAcceptedPersistentInvite + + @TokenAuthenticationHeader + @WrapResponseHeader("invite") + @POST("invitations") + suspend fun inviteToOrganization( + @Body invite: NetworkOrganizationInvite, + ): NetworkOrganizationInviteResult + + @TokenAuthenticationHeader + @WrapResponseHeader("organization") + @POST("organizations") + suspend fun registerOrganization( + @Body org: NetworkOrganizationRegistration, + ): NetworkRegisterOrganizationResult +} + +@Singleton +class RegisterApiClient @Inject constructor( + @RetrofitConfiguration(RetrofitConfigurations.CrisisCleanup) retrofit: Retrofit, +) : CrisisCleanupRegisterApi { + private val networkApi = retrofit.create(RegisterApi::class.java) + + override suspend fun registerOrgVolunteer(invite: InvitationRequest): NetworkAcceptedInvitationRequest { + val inviteRequest = NetworkInvitationRequest( + firstName = invite.firstName, + lastName = invite.lastName, + email = invite.emailAddress, + title = invite.title, + password1 = invite.password, + password2 = invite.password, + mobile = invite.mobile, + requestedTo = invite.inviterEmailAddress, + primaryLanguage = invite.languageId, + ) + return networkApi.requestInvitation(inviteRequest) + } + + private suspend fun getUserDetails(userId: Long): UserDetails { + val userInfo = networkApi.noAuthUser(userId) + val displayName = "${userInfo.firstName} ${userInfo.lastName}" + val avatarUrl = userInfo.files.profilePictureUrl?.let { URL(it) } + val orgName = networkApi.noAuthOrganization(userInfo.organization).name + return UserDetails( + displayName = displayName, + organizationName = orgName, + avatarUrl = avatarUrl, + ) + } + + override suspend fun getInvitationInfo(invite: UserPersistentInvite): OrgUserInviteInfo? { + networkApi.persistentInvitationInfo(invite.inviteToken).invite?.let { persistentInvite -> + if (persistentInvite.expiresAt < Clock.System.now()) { + return ExpiredNetworkOrgInvite + } + + val userDetails = getUserDetails(persistentInvite.id) + return OrgUserInviteInfo( + displayName = userDetails.displayName, + inviterEmail = "", + inviterAvatarUrl = userDetails.avatarUrl, + invitedEmail = "", + orgName = userDetails.organizationName, + expiration = persistentInvite.expiresAt, + isExpiredInvite = false, + ) + } + + return null + } + + override suspend fun getInvitationInfo(inviteCode: String): OrgUserInviteInfo? { + networkApi.invitationInfo(inviteCode).invite?.let { invite -> + if (invite.expiresAt < Clock.System.now()) { + return ExpiredNetworkOrgInvite + } + + val inviter = invite.inviter + val userDetails = getUserDetails(inviter.id) + return OrgUserInviteInfo( + displayName = "${inviter.firstName} ${inviter.lastName}", + inviterEmail = inviter.email, + inviterAvatarUrl = userDetails.avatarUrl, + invitedEmail = invite.inviteeEmail, + orgName = userDetails.organizationName, + expiration = invite.expiresAt, + isExpiredInvite = false, + ) + } + + return null + } + + override suspend fun acceptOrgInvitation(invite: CodeInviteAccept): JoinOrgResult { + val payload = NetworkAcceptCodeInvite( + firstName = invite.firstName, + lastName = invite.lastName, + email = invite.emailAddress, + title = invite.title, + password = invite.password, + mobile = invite.mobile, + invitationToken = invite.invitationCode, + primaryLanguage = invite.languageId, + ) + val acceptResult = networkApi.acceptInvitationFromCode(payload) + return if (acceptResult.id > 0L) { + JoinOrgResult.Success + } else { + JoinOrgResult.Unknown + } + } + + override suspend fun createPersistentInvitation( + organizationId: Long, + userId: Long, + ): NetworkPersistentInvitation { + val invitation = NetworkCreateOrgInvitation( + model = "organization_organizations", + createdBy = userId, + organizationId = organizationId, + ) + return networkApi.createPersistentInvitation(invitation).invite + ?: throw Exception("Persistent invite not created on the backend") + } + + override suspend fun acceptPersistentInvitation(invite: CodeInviteAccept): JoinOrgResult { + val payload = NetworkAcceptPersistentInvite( + firstName = invite.firstName, + lastName = invite.lastName, + email = invite.emailAddress, + title = invite.title, + password = invite.password, + mobile = invite.mobile, + token = invite.invitationCode, + ) + val response = networkApi.acceptPersistentInvitation(payload) + return when (response.detail) { + "You have been added to the organization." -> JoinOrgResult.Success + "User already a member of this organization." -> JoinOrgResult.Redundant + else -> JoinOrgResult.Unknown + } + } + + override suspend fun inviteToOrganization( + emailAddress: String, + organizationId: Long?, + ): Boolean { + val invite = + networkApi.inviteToOrganization(NetworkOrganizationInvite(emailAddress, organizationId)) + Log.w("invite", "Invite $invite $emailAddress") + return invite.invite?.inviteeEmail == emailAddress + } + + override suspend fun registerOrganization( + referer: String, + invite: IncidentOrganizationInviteInfo, + ): Boolean { + val registerOrganization = NetworkOrganizationRegistration( + name = invite.organizationName, + referral = referer, + incident = invite.incidentId, + contact = NetworkOrganizationContact( + email = invite.emailAddress, + firstName = invite.firstName, + lastName = invite.lastName, + mobile = invite.mobile, + title = null, + organization = null, + ), + ) + val organization = networkApi.registerOrganization(registerOrganization).organization + return organization.id > 0 && organization.name.lowercase() == invite.organizationName.lowercase() + } +} + +private data class UserDetails( + val displayName: String, + val organizationName: String, + val avatarUrl: URL?, +) diff --git a/feature/authentication/build.gradle.kts b/feature/authentication/build.gradle.kts index 3e66b3105..a6f97c93e 100644 --- a/feature/authentication/build.gradle.kts +++ b/feature/authentication/build.gradle.kts @@ -11,6 +11,7 @@ android { dependencies { implementation(project(":core:common")) implementation(project(":core:datastore")) + implementation(project(":core:designsystem")) implementation(project(":core:network")) // Depending modules/apps likely need to compare ktx Instants diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt index ff30cbcb5..4e87b805d 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/LoginWithPhoneViewModel.kt @@ -141,7 +141,7 @@ class LoginWithPhoneViewModel @Inject constructor( } fun onIncompleteCode() { - errorMessage = translator.translate("~~Enter a full phone code.", 0) + errorMessage = translator("~~Enter a full phone code.") } fun requestPhoneCode(phoneNumber: String) { @@ -150,7 +150,7 @@ class LoginWithPhoneViewModel @Inject constructor( val trimPhoneNumber = phoneNumber.trim() if (numberRegex.matchEntire(trimPhoneNumber) == null) { - errorMessage = translator.translate("info.enter_valid_phone", 0) + errorMessage = translator("info.enter_valid_phone") return } @@ -165,10 +165,8 @@ class LoginWithPhoneViewModel @Inject constructor( } else { // TODO Be more specific // TODO Capture error and report to backend - errorMessage = translator.translate( - "~~Phone number is invalid or phone login is down. Try again later.", - 0, - ) + errorMessage = + translator("loginWithPhone.invalid_phone_unavailable_try_again") } } finally { isRequestingCode.value = false @@ -202,7 +200,7 @@ class LoginWithPhoneViewModel @Inject constructor( isSelectAccount.value && selectedUserId == 0L ) { - errorMessage = translator.translate("~~Select an account to login with.", 0) + errorMessage = translator("loginWithPhone.select_account") return } @@ -212,7 +210,7 @@ class LoginWithPhoneViewModel @Inject constructor( ) { selectedAccount.value = PhoneNumberAccountNone isSelectAccount.value = true - errorMessage = translator.translate("~~Select an account to login with.", 0) + errorMessage = translator("loginWithPhone.select_account") return } @@ -228,10 +226,7 @@ class LoginWithPhoneViewModel @Inject constructor( if (oneTimePasswordId == 0L) { val result = verifyPhoneCode(phoneNumberInput.value, code) if (result.associatedAccounts.isEmpty()) { - errorMessage = translator.translate( - "~~There are no accounts associated with this phone number.", - 0, - ) + errorMessage = translator("loginWithPhone.no_account_error") return@launch } else { oneTimePasswordId = result.otpId @@ -267,10 +262,8 @@ class LoginWithPhoneViewModel @Inject constructor( if (emailAddress.isNotBlank() && emailAddress != accountProfile.email ) { - errorMessage = translator.translate( - "~~Logging in with an account different from the currently signed in account is not supported. Logout of the signed in account first then login with a different account.", - 0, - ) + errorMessage = + translator("loginWithPhone.log_out_before_different_account") // TODO Clear account data and support logging in with different email address? } else { val expirySeconds = @@ -291,7 +284,6 @@ class LoginWithPhoneViewModel @Inject constructor( ), ) isSuccessful = true - logger.logDebug("Phone login successful") } } } @@ -301,17 +293,14 @@ class LoginWithPhoneViewModel @Inject constructor( if (!isSuccessful && errorMessage.isBlank() ) { - errorMessage = - translator.translate("~~Login failed. Try requesting a new magic link.", 0) + errorMessage = translator("loginWithPhone.login_failed_try_again") } isAuthenticateSuccessful.value = isSuccessful } catch (e: Exception) { // TODO Be more specific on the failure where possible - errorMessage = translator.translate( - "~~Check the phone number and code is correct. If login continues to fail try again later or request a new code.", - 0, - ) + errorMessage = + translator("~~Check the phone number and code is correct. If login continues to fail try again later or request a new code.") } finally { isVerifyingCode.value = false } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt index de159ebf0..bf0610fed 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/MagicLinkLoginViewModel.kt @@ -57,9 +57,8 @@ class MagicLinkLoginViewModel @Inject constructor( val emailAddress = accountData.emailAddress if (emailAddress.isNotBlank() && emailAddress != accountProfile.email) { message = - translator.translate( - "~~Logging in with an account different from the currently signed in account is not supported. Logout of the signed in account first then login with a different account.", - 0, + translator( + "magicLink.log_out_before_different_account", ) // TODO Clear account data and support logging in with different email address? @@ -94,7 +93,7 @@ class MagicLinkLoginViewModel @Inject constructor( if (!isAuthenticateSuccessful.value) { errorMessage = message.ifBlank { - translator("~~Magic link is invalid. Request another magic link.", 0) + translator("magicLink.invalid_link", 0) } } } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt index dfef5f0fb..1854b99de 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/AuthComposables.kt @@ -1,15 +1,11 @@ package com.crisiscleanup.feature.authentication.ui -import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack @@ -20,12 +16,9 @@ import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.CrisisCleanupLogoRow import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.DayNightPreviews import com.crisiscleanup.core.designsystem.theme.LocalFontStyles @@ -73,40 +66,6 @@ internal fun LinkAction( } } -@Composable -internal fun CrisisCleanupLogoRow() { - // TODO Adjust to other screen sizes as necessary - Box(Modifier.padding(top = 16.dp, start = 8.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Start, - ) { - Image( - painterResource(R.drawable.worker_wheelbarrow_world_background), - modifier = Modifier - .testTag("ccuBackground") - .padding(top = 32.dp) - .size(width = 480.dp, height = 240.dp) - .offset(x = 64.dp), - contentScale = ContentScale.FillHeight, - contentDescription = null, - ) - } - Row( - modifier = fillWidthPadded, - horizontalArrangement = Arrangement.Start, - ) { - Image( - modifier = Modifier - .testTag("ccuLogo") - .sizeIn(maxWidth = 160.dp), - painter = painterResource(com.crisiscleanup.core.common.R.drawable.crisis_cleanup_logo), - contentDescription = stringResource(com.crisiscleanup.core.common.R.string.crisis_cleanup), - ) - } - } -} - @Composable fun LoginWithDifferentMethod( modifier: Modifier = Modifier, diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt index 208cec587..a661f59b0 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithEmailScreen.kt @@ -27,6 +27,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.OutlinedClearableTextField import com.crisiscleanup.core.designsystem.component.OutlinedObfuscatingTextField import com.crisiscleanup.core.designsystem.component.actionHeight diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt index 102bb2013..67a0e0e82 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/LoginWithPhoneScreen.kt @@ -44,6 +44,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.OutlinedClearableTextField import com.crisiscleanup.core.designsystem.component.SingleLineTextField import com.crisiscleanup.core.designsystem.component.TopAppBarCancelAction @@ -159,7 +160,7 @@ private fun LoginWithPhoneScreen( } OutlinedClearableTextField( modifier = fillWidthPadded.testTag("loginPhoneTextField"), - label = translator("~~Enter cell phone"), + label = translator("loginWithPhone.enter_cell"), value = phoneNumber, onValueChange = updateEmailInput, keyboardType = KeyboardType.Phone, @@ -219,7 +220,7 @@ private fun ColumnScope.VerifyPhoneCodeScreen( TopAppBarCancelAction( modifier = Modifier .testTag("verifyPhoneCodeBackBtn"), - title = translator.translate("actions.login", 0), + title = translator("actions.login"), onAction = onBack, ) @@ -230,10 +231,8 @@ private fun ColumnScope.VerifyPhoneCodeScreen( val obfuscatedPhoneNumber by viewModel.obfuscatedPhoneNumber.collectAsStateWithLifecycle() Column(listItemModifier) { Text( - translator.translate( - "~~Enter the ${singleCodes.size} digit code we sent to", - 0, - ), + translator("loginWithPhone.enter_x_digit_code") + .replace("{codeCount}", "${singleCodes.size}"), ) Text(obfuscatedPhoneNumber) } @@ -320,7 +319,7 @@ private fun ColumnScope.VerifyPhoneCodeScreen( } } LinkAction( - "~~Resend Code", + "actions.resend_code", modifier = Modifier .listItemPadding() .testTag("resendPhoneCodeBtn"), @@ -332,12 +331,12 @@ private fun ColumnScope.VerifyPhoneCodeScreen( val accountOptions = viewModel.accountOptions.toList() if (accountOptions.size > 1) { Text( - translator("~~This phone number is associated with multiple accounts."), + translator("loginWithPhone.phone_associated_multiple_users"), modifier = listItemModifier, ) Text( - translator("~~Select Account"), + translator("actions.select_account"), modifier = Modifier .listItemHorizontalPadding(), style = LocalFontStyles.current.header4, @@ -363,7 +362,7 @@ private fun ColumnScope.VerifyPhoneCodeScreen( Modifier.listItemVerticalPadding(), verticalAlignment = Alignment.CenterVertically, ) { - Text(selectedOption.accountDisplay.ifBlank { translator("~~Select an account") }) + Text(selectedOption.accountDisplay.ifBlank { translator("actions.select_account") }) Spacer(modifier = Modifier.weight(1f)) var tint = LocalContentColor.current if (!isNotBusy) { @@ -371,7 +370,7 @@ private fun ColumnScope.VerifyPhoneCodeScreen( } Icon( imageVector = CrisisCleanupIcons.UnfoldMore, - contentDescription = translator("~~Select account"), + contentDescription = translator("actions.select_account"), tint = tint, ) } diff --git a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt index a666f06e0..ffd96ac15 100644 --- a/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt +++ b/feature/authentication/src/main/java/com/crisiscleanup/feature/authentication/ui/MagicLinkLoginScreen.kt @@ -54,7 +54,7 @@ fun MagicLinkLoginRoute( TopAppBarBackAction( modifier = Modifier .testTag("magicLinkLoginBackBtn"), - title = translator.translate("actions.login", 0), + title = translator("actions.login"), onAction = clearStateOnBack, ) 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 35bbf0843..e6dbf9280 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 @@ -26,8 +26,10 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.LinkifyText +import com.crisiscleanup.core.designsystem.component.actionHeight import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme import com.crisiscleanup.core.designsystem.theme.DayNightPreviews import com.crisiscleanup.core.designsystem.theme.LocalFontStyles @@ -117,11 +119,11 @@ private fun AuthenticatedScreen( closeAuthentication: () -> Unit = {}, viewModel: AuthenticationViewModel = hiltViewModel(), ) { - val translator = LocalAppTranslator.current + val t = LocalAppTranslator.current Text( modifier = fillWidthPadded.testTag("authedProfileAccountInfo"), - text = translator("info.account_is") + text = t("info.account_is") .replace("{full_name}", accountData.fullName) .replace("{email_address}", accountData.emailAddress), ) @@ -135,7 +137,7 @@ private fun AuthenticatedScreen( modifier = fillWidthPadded.testTag("authedProfileLogoutBtn"), onClick = viewModel::logout, enabled = isNotBusy, - text = translator("actions.logout"), + text = t("actions.logout"), indicateBusy = !isNotBusy, ) @@ -157,7 +159,7 @@ private fun NotAuthenticatedScreen( closeAuthentication: () -> Unit = {}, hasAuthenticated: Boolean = false, ) { - val translator = LocalAppTranslator.current + val t = LocalAppTranslator.current val uriHandler = LocalUriHandler.current val registerHereLink = "https://crisiscleanup.org/register" val iNeedHelpCleaningLink = "https://crisiscleanup.org/survivor" @@ -173,7 +175,7 @@ private fun NotAuthenticatedScreen( Text( modifier = listItemModifier.testTag("loginHeaderText"), - text = translator("actions.login", R.string.login), + text = t("actions.login", R.string.login), style = LocalFontStyles.current.header1, ) @@ -186,22 +188,23 @@ private fun NotAuthenticatedScreen( .fillMaxWidth() .testTag("loginLoginWithEmailBtn"), onClick = openLoginWithEmail, - text = translator("loginForm.login_with_email", R.string.loginWithEmail), + text = t("loginForm.login_with_email", R.string.loginWithEmail), ) BusyButton( modifier = Modifier .fillMaxWidth() .testTag("loginLoginWithPhoneBtn"), onClick = openLoginWithPhone, - text = translator("loginForm.login_with_cell", R.string.loginWithPhone), + text = t("loginForm.login_with_cell", R.string.loginWithPhone), ) CrisisCleanupOutlinedButton( modifier = Modifier .fillMaxWidth() + .actionHeight() .testTag("loginVolunteerWithOrgBtn"), onClick = {}, enabled = !hasAuthenticated, - text = translator( + text = t( "actions.request_access", R.string.volunteerWithYourOrg, ), @@ -210,12 +213,13 @@ private fun NotAuthenticatedScreen( CrisisCleanupOutlinedButton( modifier = Modifier .fillMaxWidth() + .actionHeight() .testTag("loginNeedHelpCleaningBtn"), onClick = { uriHandler.openUri(iNeedHelpCleaningLink) }, enabled = true, - text = translator( + text = t( "loginForm.need_help_cleaning_up", R.string.iNeedHelpCleaningUp, ), @@ -225,7 +229,7 @@ private fun NotAuthenticatedScreen( Column( modifier = fillWidthPadded, ) { - val linkText = translator("actions.register", R.string.registerHere) + val linkText = t("actions.register", R.string.registerHere) val spannableString = SpannableString(linkText).apply { setSpan( URLSpan(registerHereLink), @@ -236,7 +240,7 @@ private fun NotAuthenticatedScreen( } Text( modifier = Modifier.testTag("loginReliefOrgAndGovText"), - text = translator( + text = t( "publicNav.relief_orgs_only", R.string.reliefOrgAndGovOnly, ), 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 7a7fab188..76749c191 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 @@ -994,7 +994,7 @@ internal fun EditExistingCaseNotesView( } val otherNotes by viewModel.otherNotes.collectAsStateWithLifecycle() - val otherNotesLabel = t("~~Other notes") + val otherNotesLabel = t("caseView.other_notes") var hideOtherNotes by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() diff --git a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt index 6a126a0f3..a7646d9a7 100644 --- a/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt +++ b/feature/cases/src/main/java/com/crisiscleanup/feature/cases/CasesTableViewDataLoader.kt @@ -91,7 +91,7 @@ class CasesTableViewDataLoader( } catch (e: Exception) { logger.logException(e) return WorksiteClaimActionResult( - errorMessage = translator.translate("info.error_case_save_mobile", 0) + errorMessage = translator("info.error_case_save_mobile") .replace("{case_number}", worksite.caseNumber), ) } finally { diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt index 53863defe..b121eb324 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt @@ -20,17 +20,21 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle 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.actionHeight import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemPadding import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy @Composable internal fun MenuRoute( + openInviteTeammate: () -> Unit = {}, openUserFeedback: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { MenuScreen( + openInviteTeammate = openInviteTeammate, openUserFeedback = openUserFeedback, openSyncLogs = openSyncLogs, ) @@ -39,6 +43,7 @@ internal fun MenuRoute( @Composable internal fun MenuScreen( viewModel: MenuViewModel = hiltViewModel(), + openInviteTeammate: () -> Unit = {}, openUserFeedback: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { @@ -59,9 +64,21 @@ internal fun MenuScreen( ) CrisisCleanupButton( - modifier = Modifier.listItemPadding(), + modifier = Modifier + .fillMaxWidth() + .listItemPadding(), + text = translator("usersVue.invite_new_user"), + onClick = openInviteTeammate, + ) + + CrisisCleanupOutlinedButton( + modifier = Modifier + .fillMaxWidth() + .listItemPadding() + .actionHeight(), text = translator("info.give_app_feedback"), onClick = openUserFeedback, + enabled = true, ) Row( diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt index 91ac3c761..975acdfe6 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/navigation/MenuNavigation.kt @@ -12,11 +12,13 @@ fun NavController.navigateToMenu(navOptions: NavOptions? = null) { } fun NavGraphBuilder.menuScreen( + openInviteTeammate: () -> Unit = {}, openUserFeedback: () -> Unit = {}, openSyncLogs: () -> Unit = {}, ) { composable(route = menuRoute) { MenuRoute( + openInviteTeammate = openInviteTeammate, openUserFeedback = openUserFeedback, openSyncLogs = openSyncLogs, ) diff --git a/feature/organizationmanage/build.gradle.kts b/feature/organizationmanage/build.gradle.kts index 9fd8fa85b..7cebc8e8b 100644 --- a/feature/organizationmanage/build.gradle.kts +++ b/feature/organizationmanage/build.gradle.kts @@ -10,4 +10,6 @@ android { dependencies { implementation(project(":core:data")) + implementation(project(":core:designsystem")) + implementation(project(":core:domain")) } \ No newline at end of file diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt index 20c566a71..5e4335339 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/InviteTeammateViewModel.kt @@ -1,6 +1,12 @@ package com.crisiscleanup.feature.organizationmanage +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.AppSettingsProvider import com.crisiscleanup.core.common.InputValidator import com.crisiscleanup.core.common.KeyResourceTranslator @@ -14,24 +20,691 @@ import com.crisiscleanup.core.data.IncidentSelectManager import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.OrgVolunteerRepository import com.crisiscleanup.core.data.repository.OrganizationsRepository +import com.crisiscleanup.core.domain.IncidentsData +import com.crisiscleanup.core.domain.LoadIncidentDataUseCase +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentOrganizationInviteInfo +import com.crisiscleanup.core.model.data.JoinOrgInvite +import com.crisiscleanup.core.model.data.OrganizationIdName import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.shareIn +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds @HiltViewModel class InviteTeammateViewModel @Inject constructor( settingsProvider: AppSettingsProvider, accountDataRepository: AccountDataRepository, organizationsRepository: OrganizationsRepository, - orgVolunteerRepository: OrgVolunteerRepository, - inputValidator: InputValidator, + private val orgVolunteerRepository: OrgVolunteerRepository, + private val inputValidator: InputValidator, qrCodeGenerator: QrCodeGenerator, + loadIncidentDataUseCase: LoadIncidentDataUseCase, incidentSelectManager: IncidentSelectManager, - translator: KeyResourceTranslator, - @Dispatcher(IO) ioDispatcher: CoroutineDispatcher, + private val translator: KeyResourceTranslator, + @Dispatcher(IO) private val ioDispatcher: CoroutineDispatcher, @Logger(Onboarding) private val logger: AppLogger, ) : ViewModel() { - private val inviteUrl = "${settingsProvider.baseUrl}/mobile_app_user_invite" -} \ No newline at end of file + val isValidatingAccount = MutableStateFlow(false) + + val accountData = accountDataRepository.accountData + .shareIn( + scope = viewModelScope, + replay = 1, + started = SharingStarted.WhileSubscribed(), + ) + val hasValidTokens = accountData.map { it.areTokensValid } + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(), + ) + + private val incidentsData = loadIncidentDataUseCase() + + val inviteToAnotherOrg = MutableStateFlow(false) + private val affiliateOrganizationIds = MutableStateFlow?>(null) + val selectedOtherOrg = MutableStateFlow(OrganizationIdName(0, "")) + val organizationNameQuery = MutableStateFlow("") + private val isSearchingLocalOrganizations = MutableStateFlow(false) + private val isSearchingNetworkOrganizations = MutableStateFlow(false) + val isSearchingOrganizations = combine( + isSearchingLocalOrganizations, + isSearchingNetworkOrganizations, + ::Pair, + ) + .map { it.first || it.second } + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(), + ) + + private val otherOrgQuery = combine( + inviteToAnotherOrg, + organizationNameQuery, + ::Pair, + ) + .map { (inviteToAnother, q) -> if (inviteToAnother) q else "" } + val inviteOrgState = combine( + inviteToAnotherOrg, + selectedOtherOrg, + otherOrgQuery, + affiliateOrganizationIds, + ) { inviteToAnother, selectedOther, otherQ, affiliateIds -> + Pair( + Pair(inviteToAnother, selectedOther), + Pair(otherQ, affiliateIds), + ) + } + .filter { (_, b) -> + val affiliates = b.second + affiliates != null + } + .map { (a, b) -> + val (inviteToAnother, selected) = a + val (q, affiliates) = b + var isNew = false + var isAffiliate = false + var isNonAffiliate = false + + if (inviteToAnother && + q.isNotBlank() + ) { + if (selected.id > 0 && + q.trim() == selected.name.trim() + ) { + if (affiliates!!.contains(selected.id)) { + isAffiliate = true + } else { + isNonAffiliate = true + } + } + + isNew = !(isAffiliate || isNonAffiliate) + } + + InviteOrgState( + own = !inviteToAnother, + affiliate = isAffiliate, + nonAffiliate = isNonAffiliate, + new = isNew, + ) + } + .stateIn( + scope = viewModelScope, + initialValue = InviteOrgState( + own = false, + affiliate = false, + nonAffiliate = false, + new = false, + ), + started = SharingStarted.WhileSubscribed(), + ) + + private val qFlow = organizationNameQuery + .debounce(0.3.seconds) + .map(String::trim) + .shareIn( + scope = viewModelScope, + replay = 1, + started = SharingStarted.WhileSubscribed(), + ) + + val organizationsSearchResult = qFlow + .flatMapLatest { q -> + // TODO Indicate loading (in thread safe manner) when querying local matches + // TODO Data layer (streamMatchingOrganizations) needs testing + if (q.isNotBlank()) { + organizationsRepository.streamMatchingOrganizations(q) + .map { OrgSearch(q, it) } + } else { + flowOf(EmptyOrgSearch) + } + } + .stateIn( + scope = viewModelScope, + initialValue = EmptyOrgSearch, + started = SharingStarted.WhileSubscribed(), + ) + + var inviteEmailAddresses by mutableStateOf("") + var invitePhoneNumber by mutableStateOf("") + var inviteFirstName by mutableStateOf("") + var inviteLastName by mutableStateOf("") + var emailAddressError by mutableStateOf("") + var phoneNumberError by mutableStateOf("") + var firstNameError by mutableStateOf("") + var lastNameError by mutableStateOf("") + var selectedIncidentError by mutableStateOf("") + + val incidents = MutableStateFlow(emptyList()) + val incidentLookup = MutableStateFlow(emptyMap()) + var selectedIncidentId by mutableStateOf(EmptyIncident.id) + + // TODO Size QR codes relative to min screen dimension + private val qrCodeSize = 512 + 256 + + private val isCreatingMyOrgPersistentInvitation = MutableStateFlow(false) + private val joinMyOrgInvite = MutableStateFlow(null) + private val isGeneratingMyOrgQrCode = MutableStateFlow(false) + val myOrgInviteQrCode = combine( + accountData, + joinMyOrgInvite, + ::Pair, + ) + .map { (account, orgInvite) -> + isGeneratingMyOrgQrCode.value = true + try { + orgInvite?.let { invite -> + if (!invite.isExpired) { + val inviteUrl = makeInviteUrl(account.id, invite) + return@map qrCodeGenerator.generate(inviteUrl, qrCodeSize)?.asImageBitmap() + } + } + } finally { + isGeneratingMyOrgQrCode.value = false + } + + null + } + .stateIn( + scope = viewModelScope, + initialValue = null, + started = SharingStarted.WhileSubscribed(), + ) + + // TODO Test affiliate org features when supported + private val generatingAffiliateOrgQrCode = MutableStateFlow(0L) + private val affiliateOrgInviteQrCode = combine( + accountData, + selectedOtherOrg, + affiliateOrganizationIds, + ::Triple, + ) + .filter { (accountData, otherOrg, affiliates) -> + accountData.hasAuthenticated && + otherOrg.id > 0 && + affiliates?.contains(otherOrg.id) == true + } + .map { (account, otherOrgIdName, _) -> + withContext(ioDispatcher) { + val otherOrgId = otherOrgIdName.id + generatingAffiliateOrgQrCode.value = otherOrgId + try { + val userId = account.id + val invite = orgVolunteerRepository.getOrganizationInvite( + organizationId = otherOrgId, + inviterUserId = userId, + ) + + ensureActive() + + val inviteUrl = makeInviteUrl(account.id, invite) + val qrCode = qrCodeGenerator.generate(inviteUrl, qrCodeSize)?.asImageBitmap() + + ensureActive() + + OrgQrCode(otherOrgId, qrCode) + } finally { + // TODO Atomic update + if (generatingAffiliateOrgQrCode.value == otherOrgId) { + generatingAffiliateOrgQrCode.value = 0 + } + } + } + } + + val affiliateOrgQrCode = combine( + inviteToAnotherOrg, + inviteOrgState, + selectedOtherOrg, + affiliateOrgInviteQrCode, + ) { inviteToAnother, inviteOrg, selectedOther, invite -> + Pair( + Pair(inviteToAnother, inviteOrg), + Pair(selectedOther, invite), + ) + } + .map { (a, b) -> + val (inviteToAnother, inviteOrg) = a + val (selectedOther, invite) = b + if (inviteToAnother && + inviteOrg.ownOrAffiliate && + invite.orgId > 0 && + selectedOther.id == invite.orgId + ) { + invite.qrCode + } else { + null + } + } + .distinctUntilChanged() + .stateIn( + scope = viewModelScope, + initialValue = null, + started = SharingStarted.WhileSubscribed(), + ) + + private val isGeneratingAffiliateQrCode = combine( + generatingAffiliateOrgQrCode, + selectedOtherOrg, + ::Pair, + ) + .map { (generatingOrgId, selectedOrg) -> + generatingOrgId > 0L && generatingOrgId == selectedOrg.id + } + .shareIn( + scope = viewModelScope, + replay = 1, + started = SharingStarted.WhileSubscribed(), + ) + + val isGeneratingQrCode = combine( + isCreatingMyOrgPersistentInvitation, + isGeneratingMyOrgQrCode, + isGeneratingAffiliateQrCode, + ::Triple, + ) + .map { it.first || it.second || it.third } + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(), + ) + + val isSendingInvite = MutableStateFlow(false) + val isInviteSent = MutableStateFlow(false) + var inviteSentTitle by mutableStateOf("") + var inviteSentText by mutableStateOf("") + + val sendInviteErrorMessage = MutableStateFlow("") + + val isLoading = combine( + isValidatingAccount, + affiliateOrganizationIds, + incidentsData, + ::Triple, + ) + .map { (b0, affiliateIds, incidents) -> + b0 || affiliateIds == null || incidents is IncidentsData.Loading + } + .stateIn( + scope = viewModelScope, + initialValue = true, + started = SharingStarted.WhileSubscribed(), + ) + val isEditable = isSendingInvite + .map(Boolean::not) + .stateIn( + scope = viewModelScope, + initialValue = false, + started = SharingStarted.WhileSubscribed(), + ) + + val myOrgInviteOptionText = accountData.map { + translator("inviteTeammates.part_of_my_org") + .replace("{my_organization}", it.org.name) + } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(), + ) + + val anotherOrgInviteOptionText = translator("inviteTeammates.from_another_org") + + val scanQrCodeText = combine( + accountData, + inviteToAnotherOrg, + ::Pair, + ) + .filter { (account, _) -> account.id > 0 } + .map { (account, inviteToOther) -> + if (inviteToOther) { + translator("inviteTeammates.invite_via_qr") + } else { + translator("inviteTeammates.scan_qr_code_to_invite") + .replace("{organization}", account.org.name) + } + } + .stateIn( + scope = viewModelScope, + initialValue = "", + started = SharingStarted.WhileSubscribed(), + ) + + init { + incidentsData + .filter { it is IncidentsData.Incidents } + .onEach { + withContext(ioDispatcher) { + val incidents = (it as IncidentsData.Incidents).incidents + this@InviteTeammateViewModel.incidents.value = incidents + incidentLookup.value = incidents.associateBy(Incident::id) + } + } + .launchIn(viewModelScope) + + incidentSelectManager.incidentId + .onEach { + if (selectedIncidentId <= 0) { + selectedIncidentId = it + } + } + .launchIn(viewModelScope) + + hasValidTokens + .onEach { + if (it) { + isValidatingAccount.value = false + } + } + .launchIn(viewModelScope) + + viewModelScope.launch(ioDispatcher) { + val orgId = accountData.first().org.id + // TODO Handle sync fail accordingly + organizationsRepository.syncOrganization(orgId, force = true, updateLocations = false) + affiliateOrganizationIds.value = + organizationsRepository.getOrganizationAffiliateIds(orgId, false) + } + + qFlow + .filter { it.length > 1 } + .onEach { q -> + withContext(ioDispatcher) { + // TODO Review loading pattern and fix as necessary + isSearchingNetworkOrganizations.value = true + try { + organizationsRepository.searchOrganizations(q) + } finally { + isSearchingNetworkOrganizations.value = false + } + } + } + .launchIn(viewModelScope) + + organizationNameQuery + .onEach { q -> + if (q.isNotBlank()) { + sendInviteErrorMessage.value = "" + } + } + .launchIn(viewModelScope) + + accountData + .filter { it.hasAuthenticated } + .map { data -> + withContext(ioDispatcher) { + isCreatingMyOrgPersistentInvitation.value = true + try { + val orgId = data.org.id + + joinMyOrgInvite.value?.let { invite -> + if (invite.orgId == orgId && + !invite.isExpired + ) { + return@withContext invite + } + } + + orgVolunteerRepository.getOrganizationInvite(orgId, data.id) + } finally { + isCreatingMyOrgPersistentInvitation.value = false + } + } + } + .onEach { + joinMyOrgInvite.value = it + } + .launchIn(viewModelScope) + } + + private fun makeInviteUrl(userId: Long, invite: JoinOrgInvite): String { + return "$inviteUrl?org-id=${invite.orgId}&user-id=$userId&invite-token=${invite.token}" + } + + fun onSelectOrganization(organization: OrganizationIdName) { + selectedOtherOrg.value = organization + organizationNameQuery.value = organization.name + } + + private fun validateSendEmailAddresses(): List { + val emailAddresses = inviteEmailAddresses.split(",") + .map(String::trim) + .filter(String::isNotBlank) + val errorMessage = if (emailAddresses.isEmpty()) { + translator("inviteTeammates.enter_invited_emails") + } else { + val invalidEmailAddresses = emailAddresses.map { s -> + if (inputValidator.validateEmailAddress(s)) { + "" + } else { + translator("inviteTeammates.invalid_email_error") + .replace("{email}", s) + } + } + invalidEmailAddresses.filter(String::isNotBlank) + .joinToString("\n") + } + + if (errorMessage.isNotBlank()) { + emailAddressError = errorMessage + return emptyList() + } + + return emailAddresses + } + + fun onOrgQueryClose() { + var matchingOrg = OrganizationIdName(0, "") + val q = organizationNameQuery.value.trim() + val orgQueryLower = q.lowercase() + // TODO Optimize matching if result set is computationally large + for (result in organizationsSearchResult.value.organizations) { + if (result.name.trim().lowercase() == orgQueryLower) { + matchingOrg = result + break + } + } + if (selectedOtherOrg.value.id != matchingOrg.id) { + matchingOrg = OrganizationIdName(0, q) + selectedOtherOrg.value = matchingOrg + organizationNameQuery.value = matchingOrg.name + } + } + + private suspend fun inviteToOrgOrAffiliate( + emailAddresses: List, + organizationId: Long? = null, + ): Boolean { + val notInvited = mutableListOf() + for (emailAddress in emailAddresses) { + val invited = orgVolunteerRepository.inviteToOrganization(emailAddress, organizationId) + if (!invited) { + notInvited.add(emailAddress) + } + } + + if (notInvited.isNotEmpty()) { + sendInviteErrorMessage.value = + translator("inviteTeammates.emails_not_invited_error") + .replace("{email_addresses}", notInvited.joinToString("\n ")) + return false + } + return true + } + + private fun onInviteSent(title: String, text: String) { + inviteSentTitle = title + inviteSentText = text + isInviteSent.value = true + } + + private fun onInviteSentToOrgOrAffiliate(emailAddresses: List) { + onInviteSent( + title = translator("inviteTeammates.invitations_sent"), + text = translator(emailAddresses.joinToString("\n")), + ) + } + + fun onSendInvites() { + if (isSendingInvite.value) { + return + } + isSendingInvite.value = true + viewModelScope.launch(ioDispatcher) { + try { + sendInvites() + } catch (e: Exception) { + sendInviteErrorMessage.value = + translator("~~Invites are broken. Sorry for the inconvenience. Please try again later.") + logger.logException(e) + } finally { + isSendingInvite.value = false + } + } + } + + private suspend fun sendInvites() { + emailAddressError = "" + phoneNumberError = "" + firstNameError = "" + lastNameError = "" + selectedIncidentError = "" + + sendInviteErrorMessage.value = "" + + val emailAddress = accountData.first().emailAddress + val myEmailAddressLower = emailAddress.trim().lowercase() + val emailAddresses = validateSendEmailAddresses() + .filter { s -> s.lowercase() != myEmailAddressLower } + if (emailAddresses.isEmpty()) { + return + } + + val inviteState = inviteOrgState.first() + if (inviteState.new) { + if (emailAddresses.size > 1) { + emailAddressError = translator("registerOrg.only_one_email_allowed") + return + } + + if (invitePhoneNumber.isBlank()) { + phoneNumberError = translator("registerOrg.phone_required") + return + } + + if (inviteFirstName.isBlank()) { + firstNameError = translator("registerOrg.first_name_required") + return + } + + if (inviteLastName.isBlank()) { + lastNameError = translator("registerOrg.last_name_required") + return + } + + if (selectedIncidentId == EmptyIncident.id) { + selectedIncidentError = translator("registerOrg.select_incident") + return + } + } + + val q = organizationNameQuery.value + if (inviteToAnotherOrg.value) { + if (selectedOtherOrg.value.id > 0) { + if (selectedOtherOrg.value.name != q) { + sendInviteErrorMessage.value = translator("registerOrg.search_and_select_org") + return + } + } else { + if (q.trim().isBlank()) { + sendInviteErrorMessage.value = + translator("registerOrg.search_and_select_org_blank") + return + } + } + } + + var isSentToOrgOrAffiliate = false + if (inviteToAnotherOrg.value) { + if (inviteState.new && emailAddresses.size == 1) { + val organizationName = q.trim() + val emailContact = emailAddresses[0] + val isRegisterNewOrganization = orgVolunteerRepository.createOrganization( + referer = emailAddress, + invite = IncidentOrganizationInviteInfo( + incidentId = selectedIncidentId, + organizationName = organizationName, + emailAddress = emailContact, + mobile = invitePhoneNumber, + firstName = inviteFirstName, + lastName = inviteLastName, + ), + ) + + if (isRegisterNewOrganization) { + onInviteSent( + title = translator("registerOrg.you_have_registered_org") + .replace("{organization}", organizationName), + text = translator("registerOrg.we_will_finalize_registration") + .replace("{email}", emailContact), + ) + } + } else if (inviteState.affiliate) { + isSentToOrgOrAffiliate = + inviteToOrgOrAffiliate(emailAddresses, selectedOtherOrg.value.id) + } else if (inviteState.nonAffiliate) { + // TODO Finish when API supports a corresponding endpoint + } + } else { + isSentToOrgOrAffiliate = inviteToOrgOrAffiliate(emailAddresses) + } + + if (isSentToOrgOrAffiliate) { + onInviteSentToOrgOrAffiliate(emailAddresses) + } + } +} + +data class OrgSearch( + val q: String, + val organizations: List, +) + +private val EmptyOrgSearch = OrgSearch("", emptyList()) + +data class OrgQrCode( + val orgId: Long, + val qrCode: ImageBitmap?, +) + +data class InviteOrgState( + val own: Boolean, + val affiliate: Boolean, + val nonAffiliate: Boolean, + val new: Boolean, + val ownOrAffiliate: Boolean = own || affiliate, +) diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt deleted file mode 100644 index 7a6c05f02..000000000 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/di/OrganizationManageModule.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.crisiscleanup.feature.organizationmanage.di - -import dagger.Module -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent - -@Module -@InstallIn(SingletonComponent::class) -interface OrganizationManageModule { -} diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt index a897c7052..0add22c05 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/navigation/InviteTeammateNavigation.kt @@ -1,2 +1,20 @@ package com.crisiscleanup.feature.organizationmanage.navigation +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.crisiscleanup.core.appnav.RouteConstant +import com.crisiscleanup.feature.organizationmanage.ui.InviteTeammateRoute + +fun NavController.navigateToInviteTeammate(navOptions: NavOptions? = null) { + this.navigate(RouteConstant.inviteTeammateRoute, navOptions) +} + +fun NavGraphBuilder.inviteTeammateScreen( + onBack: () -> Unit = {}, +) { + composable(route = RouteConstant.inviteTeammateRoute) { + InviteTeammateRoute(onBack = onBack) + } +} diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt index 5486a022a..f093a7cf2 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/InviteTeammateScreen.kt @@ -1,10 +1,698 @@ package com.crisiscleanup.feature.organizationmanage.ui +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.geometry.Size +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +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.designsystem.LocalAppTranslator +import com.crisiscleanup.core.designsystem.component.AnimatedBusyIndicator +import com.crisiscleanup.core.designsystem.component.BusyButton +import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter +import com.crisiscleanup.core.designsystem.component.CrisisCleanupLogoRow +import com.crisiscleanup.core.designsystem.component.CrisisCleanupRadioButton +import com.crisiscleanup.core.designsystem.component.OutlinedClearableTextField +import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction +import com.crisiscleanup.core.designsystem.component.actionHeight +import com.crisiscleanup.core.designsystem.component.roundedOutline +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.CrisisCleanupTheme +import com.crisiscleanup.core.designsystem.theme.DayNightPreviews +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.listItemBottomPadding +import com.crisiscleanup.core.designsystem.theme.listItemDropdownMenuOffset +import com.crisiscleanup.core.designsystem.theme.listItemHeight +import com.crisiscleanup.core.designsystem.theme.listItemHorizontalPadding +import com.crisiscleanup.core.designsystem.theme.listItemModifier +import com.crisiscleanup.core.designsystem.theme.listItemPadding +import com.crisiscleanup.core.designsystem.theme.listItemTopPadding +import com.crisiscleanup.core.designsystem.theme.listItemVerticalPadding +import com.crisiscleanup.core.designsystem.theme.neutralFontColor +import com.crisiscleanup.core.designsystem.theme.optionItemHeight +import com.crisiscleanup.core.designsystem.theme.primaryBlueColor +import com.crisiscleanup.core.designsystem.theme.primaryRedColor +import com.crisiscleanup.core.designsystem.theme.statusClosedColor +import com.crisiscleanup.core.model.data.EmptyIncident +import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.OrganizationIdName +import com.crisiscleanup.core.ui.rememberCloseKeyboard +import com.crisiscleanup.core.ui.scrollFlingListener +import com.crisiscleanup.feature.organizationmanage.InviteOrgState +import com.crisiscleanup.feature.organizationmanage.InviteTeammateViewModel +@OptIn(ExperimentalMaterial3Api::class) @Composable fun InviteTeammateRoute( + onBack: () -> Unit = {}, + viewModel: InviteTeammateViewModel = hiltViewModel(), +) { + val isLoading by viewModel.isLoading.collectAsStateWithLifecycle() + val isInviteSent by viewModel.isInviteSent.collectAsStateWithLifecycle() + val hasValidTokens by viewModel.hasValidTokens.collectAsStateWithLifecycle() + + val t = LocalAppTranslator.current + Column(Modifier.fillMaxSize()) { + TopAppBarBackAction( + title = t("nav.invite_teammates"), + onAction = onBack, + ) + + if (isLoading) { + Box(Modifier.fillMaxSize()) { + BusyIndicatorFloatingTopCenter(true) + } + } else if (isInviteSent) { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RegisterSuccessView( + title = viewModel.inviteSentTitle, + text = viewModel.inviteSentText, + ) + } + } else if (hasValidTokens) { + InviteTeammateContent() + } else { + Text( + t("inviteTeammates.sign_in_to_invite"), + listItemModifier, + style = LocalFontStyles.current.header2, + ) + } + } +} +@Composable +fun InviteTeammateContent( + viewModel: InviteTeammateViewModel = hiltViewModel(), ) { + val t = LocalAppTranslator.current + val closeKeyboard = rememberCloseKeyboard(viewModel) + + val isEditable by viewModel.isEditable.collectAsStateWithLifecycle() + + val inviteToAnotherOrg by viewModel.inviteToAnotherOrg.collectAsStateWithLifecycle() + val inviteToMyOrgText by viewModel.myOrgInviteOptionText.collectAsStateWithLifecycle() + val inviteToAnotherOrgText = viewModel.anotherOrgInviteOptionText + val onChangeInvite = remember(viewModel) { + { inviteToAnother: Boolean -> + viewModel.inviteToAnotherOrg.value = inviteToAnother + } + } + + val organizationNameQuery by viewModel.organizationNameQuery.collectAsStateWithLifecycle() + val orgQueryResult by viewModel.organizationsSearchResult.collectAsStateWithLifecycle() + val matchingOrganizations = orgQueryResult.organizations + var dismissOrganizationQuery by remember { mutableStateOf("") } + val isOrganizationVisible by remember( + matchingOrganizations, + dismissOrganizationQuery, + organizationNameQuery, + ) { + derivedStateOf { + organizationNameQuery.trim() == orgQueryResult.q && + matchingOrganizations.isNotEmpty() && + dismissOrganizationQuery != organizationNameQuery + } + } + val onOrgSelect = remember(viewModel) { + { organization: OrganizationIdName -> + dismissOrganizationQuery = organization.name + viewModel.onSelectOrganization(organization) + } + } + val onDismissOrgOptions = remember(viewModel) { + { + dismissOrganizationQuery = organizationNameQuery + viewModel.onOrgQueryClose() + } + } + + val inviteOrgState by viewModel.inviteOrgState.collectAsStateWithLifecycle() + val isNewOrganization = inviteOrgState.new + val searchOrgStartSpace = 48.dp + + val sendInviteErrorMessage by viewModel.sendInviteErrorMessage.collectAsStateWithLifecycle() + val isSendingInvite by viewModel.isSendingInvite.collectAsStateWithLifecycle() + + var focusOnOrgName by remember { mutableStateOf(false) } + + Column( + Modifier + .scrollFlingListener(closeKeyboard) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + Text( + t("~~Invite new user via email invitation link"), + listItemModifier, + style = LocalFontStyles.current.header4, + ) + + val radioModifier = Modifier + .fillMaxWidth() + .listItemHeight() + .listItemPadding() + CrisisCleanupRadioButton( + radioModifier, + !inviteToAnotherOrg, + text = inviteToMyOrgText, + onSelect = { + onChangeInvite(false) + focusOnOrgName = false + }, + enabled = isEditable, + ) + CrisisCleanupRadioButton( + radioModifier, + inviteToAnotherOrg, + text = inviteToAnotherOrgText, + onSelect = { + onChangeInvite(true) + + if (organizationNameQuery.isBlank()) { + focusOnOrgName = true + } + }, + enabled = isEditable, + ) + + Column( + Modifier + .listItemHorizontalPadding() + .padding(start = searchOrgStartSpace), + ) { + OrgQueryInput( + isOrganizationsVisible = isOrganizationVisible, + isEditable = isEditable && inviteToAnotherOrg, + hasFocus = focusOnOrgName, + organizationNameQuery = organizationNameQuery, + updateOrgNameQuery = { + dismissOrganizationQuery = "" + viewModel.organizationNameQuery.value = it + }, + organizations = matchingOrganizations, + onOrgSelect = onOrgSelect, + onDismissDropdown = onDismissOrgOptions, + ) + + if (inviteToAnotherOrg) { + var messageKey = "" + if (isNewOrganization) { + // TODO Hide or show loading when orgs are being queried + if (!isOrganizationVisible) { + messageKey = "inviteTeammates.org_does_not_have_account" + } + } else if (inviteOrgState.nonAffiliate) { + // TODO Update once logic is decided + // return "inviteTeammates.user_needs_approval_from_org" + } + if (messageKey.isNotBlank()) { + Text( + t(messageKey), + Modifier.listItemVerticalPadding(), + color = primaryBlueColor, + ) + } + } + } + + UserInfoErrorText( + viewModel.emailAddressError, + Modifier.padding(top = 16.dp), + ) + + if (inviteOrgState.nonAffiliate) { + Text( + t("inviteTeammates.no_unaffiliated_invitations_allowed"), + listItemModifier, + color = primaryBlueColor, + ) + } else { + val hasEmailError = viewModel.emailAddressError.isNotBlank() + val emailLabel = t("invitationsVue.email") + OutlinedClearableTextField( + modifier = listItemModifier, + labelResId = 0, + label = emailLabel, + value = viewModel.inviteEmailAddresses, + onValueChange = { viewModel.inviteEmailAddresses = it }, + leadingIcon = { + Icon( + imageVector = CrisisCleanupIcons.Mail, + contentDescription = emailLabel, + ) + }, + enabled = isEditable, + hasFocus = hasEmailError, + isError = hasEmailError, + ) -} \ No newline at end of file + if (isNewOrganization) { + // TODO Hide or show loading when orgs are being queried + if (!isOrganizationVisible) { + NewOrganizationInput(isEditable) + } + } else { + Text( + t("inviteTeammates.use_commas_multiple_emails"), + Modifier + .listItemHorizontalPadding() + // TODO Common dimensions + .padding(bottom = 16.dp), + style = MaterialTheme.typography.bodySmall, + ) + } + } + + UserInfoErrorText(sendInviteErrorMessage) + + BusyButton( + modifier = listItemModifier + // TODO Common dimensions + .padding(bottom = 16.dp), + onClick = viewModel::onSendInvites, + enabled = !inviteOrgState.nonAffiliate, + text = t("inviteTeammates.send_invites"), + indicateBusy = isSendingInvite, + ) + + QrCodeSection(inviteToAnotherOrg, inviteOrgState) + } +} + +@Composable +private fun UserInfoErrorText( + message: String, + modifier: Modifier = Modifier, +) { + if (message.isNotBlank()) { + Text( + message, + modifier.then( + Modifier + .listItemHorizontalPadding() + .listItemTopPadding(), + ), + color = primaryRedColor, + ) + } +} + +@Composable +private fun OrgQueryInput( + isOrganizationsVisible: Boolean, + isEditable: Boolean, + hasFocus: Boolean, + organizationNameQuery: String, + updateOrgNameQuery: (String) -> Unit, + organizations: List, + onOrgSelect: (OrganizationIdName) -> Unit, + onDismissDropdown: () -> Unit, +) { + val t = LocalAppTranslator.current + + Box(Modifier.fillMaxWidth()) { + var contentWidth by remember { mutableStateOf(Size.Zero) } + + OutlinedClearableTextField( + modifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { + contentWidth = it.size.toSize() + }, + label = t("profileOrg.organization_name"), + value = organizationNameQuery, + onValueChange = updateOrgNameQuery, + keyboardType = KeyboardType.Password, + keyboardCapitalization = KeyboardCapitalization.Words, + enabled = isEditable, + isError = false, + hasFocus = hasFocus, + ) + + if (organizations.isNotEmpty()) { + val onSelect = { organization: OrganizationIdName -> + onOrgSelect(organization) + } + + val context = LocalContext.current + val displayMetrics = context.resources.displayMetrics + val heightPx = displayMetrics.heightPixels + val maxHeight = remember(heightPx) { + val density = displayMetrics.density + val heightDp = heightPx * 0.4 / density + min(heightDp.dp, 480.dp) + } + + DropdownMenu( + modifier = Modifier + .sizeIn(maxHeight = maxHeight) + .width( + with(LocalDensity.current) { + contentWidth.width + .toDp() + .minus(listItemDropdownMenuOffset.x.times(2)) + }, + ), + expanded = isOrganizationsVisible, + onDismissRequest = onDismissDropdown, + offset = listItemDropdownMenuOffset, + properties = PopupProperties(focusable = false), + ) { + DropdownOrganizationItems( + organizations, + ) { + onSelect(it) + } + } + } + } +} + +@Composable +private fun DropdownOrganizationItems( + organizations: List, + onSelect: (OrganizationIdName) -> Unit, +) { + for (organization in organizations) { + key(organization.id) { + DropdownMenuItem( + modifier = Modifier.optionItemHeight(), + text = { + Text( + organization.name, + style = LocalFontStyles.current.header4, + ) + }, + onClick = { onSelect(organization) }, + ) + } + } +} + +@Composable +private fun NewOrganizationInput( + isEditable: Boolean, + viewModel: InviteTeammateViewModel = hiltViewModel(), +) { + val t = LocalAppTranslator.current + val closeKeyboard = rememberCloseKeyboard(viewModel) + + val phoneLabel = t("invitationSignup.mobile_placeholder") + val hasPhoneError = viewModel.phoneNumberError.isNotBlank() + UserInfoErrorText(viewModel.phoneNumberError) + OutlinedClearableTextField( + modifier = listItemModifier, + value = viewModel.invitePhoneNumber, + onValueChange = { viewModel.invitePhoneNumber = it }, + label = phoneLabel, + leadingIcon = { + Icon( + imageVector = CrisisCleanupIcons.Phone, + contentDescription = phoneLabel, + ) + }, + keyboardType = KeyboardType.Password, + enabled = isEditable, + hasFocus = hasPhoneError, + isError = hasPhoneError, + ) + + UserInfoErrorText(viewModel.firstNameError) + val hasFirstNameError = viewModel.firstNameError.isNotBlank() + OutlinedClearableTextField( + modifier = listItemModifier, + value = viewModel.inviteFirstName, + onValueChange = { viewModel.inviteFirstName = it }, + label = t("invitationSignup.first_name_placeholder"), + keyboardType = KeyboardType.Text, + keyboardCapitalization = KeyboardCapitalization.Words, + enabled = isEditable, + hasFocus = hasFirstNameError, + isError = hasFirstNameError, + ) + + val hasLastNameError = viewModel.lastNameError.isNotBlank() + UserInfoErrorText(viewModel.lastNameError) + OutlinedClearableTextField( + modifier = listItemModifier, + value = viewModel.inviteLastName, + onValueChange = { viewModel.inviteLastName = it }, + label = t("invitationSignup.last_name_placeholder"), + keyboardType = KeyboardType.Text, + keyboardCapitalization = KeyboardCapitalization.Words, + enabled = isEditable, + hasFocus = hasLastNameError, + isError = hasLastNameError, + imeAction = ImeAction.Done, + onEnter = closeKeyboard, + ) + + UserInfoErrorText(viewModel.selectedIncidentError) + val incidentLookup by viewModel.incidentLookup.collectAsStateWithLifecycle() + val selectedIncident = incidentLookup[viewModel.selectedIncidentId] ?: EmptyIncident + val selectIncidentHint = t("~~Select Incident") + val incidents by viewModel.incidents.collectAsStateWithLifecycle() + Box( + Modifier + .listItemBottomPadding() + .fillMaxWidth(), + ) { + var contentWidth by remember { mutableStateOf(Size.Zero) } + var showDropdown by remember { mutableStateOf(false) } + Row( + Modifier + .padding(16.dp) + .actionHeight() + .roundedOutline(radius = 3.dp) + .clickable( + onClick = { showDropdown = !showDropdown }, + enabled = isEditable, + ) + .listItemPadding() + .onGloballyPositioned { + contentWidth = it.size.toSize() + }, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedIncident.name.ifBlank { selectIncidentHint }) + Spacer(Modifier.weight(1f)) + Icon( + imageVector = CrisisCleanupIcons.ExpandAll, + contentDescription = selectIncidentHint, + ) + } + + if (incidents.isNotEmpty()) { + val onSelect = { incident: Incident -> + viewModel.selectedIncidentId = incident.id + showDropdown = false + } + DropdownMenu( + modifier = Modifier + .width( + with(LocalDensity.current) { + contentWidth.width.toDp().minus(listItemDropdownMenuOffset.x.times(2)) + }, + ), + expanded = showDropdown, + onDismissRequest = { showDropdown = false }, + offset = listItemDropdownMenuOffset, + ) { + DropdownIncidentItems( + incidents, + ) { + onSelect(it) + } + } + } + } +} + +@Composable +private fun DropdownIncidentItems( + incidents: List, + onSelect: (Incident) -> Unit, +) { + for (incident in incidents) { + key(incident.id) { + DropdownMenuItem( + modifier = Modifier.optionItemHeight(), + text = { + Text( + incident.name, + style = LocalFontStyles.current.header4, + ) + }, + onClick = { onSelect(incident) }, + ) + } + } +} + +@Composable +private fun QrCodeSection( + inviteToAnotherOrg: Boolean, + inviteOrgState: InviteOrgState, + viewModel: InviteTeammateViewModel = hiltViewModel(), +) { + val scanQrCodeText by viewModel.scanQrCodeText.collectAsStateWithLifecycle() + if (inviteOrgState.ownOrAffiliate && scanQrCodeText.isNotBlank()) { + val t = LocalAppTranslator.current + + val orText = t("inviteTeammates.or") + Text( + orText, + // TODO Common dimensions + Modifier + .padding(16.dp) + .fillMaxWidth(), + style = LocalFontStyles.current.header4, + textAlign = TextAlign.Center, + color = neutralFontColor, + ) + + Text( + scanQrCodeText, + listItemModifier, + style = LocalFontStyles.current.header4, + ) + + val isGeneratingQrCode by viewModel.isGeneratingQrCode.collectAsStateWithLifecycle() + if (isGeneratingQrCode) { + Row( + Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + AnimatedBusyIndicator( + true, + padding = 16.dp, + ) + } + } else { + if (inviteToAnotherOrg) { + val qrCode by viewModel.affiliateOrgQrCode.collectAsStateWithLifecycle() + qrCode?.let { + CenteredRowImage(image = it) + } + } else { + val qrCode by viewModel.myOrgInviteQrCode.collectAsStateWithLifecycle() + if (qrCode == null) { + UserInfoErrorText(t("inviteTeammates.invite_error")) + } else { + CenteredRowImage(image = qrCode!!) + } + } + } + } +} + +@Composable +private fun CenteredRowImage( + image: ImageBitmap, +) { + Row( + Modifier + .fillMaxWidth() + // TODO Common dimensions + .padding(16.dp), + horizontalArrangement = Arrangement.Center, + ) { + Image(bitmap = image, contentDescription = null) + } +} + +@Composable +internal fun ColumnScope.RegisterSuccessView( + title: String, + text: String, +) { + Spacer(Modifier.weight(1f)) + + Icon( + modifier = Modifier.size(64.dp), + imageVector = CrisisCleanupIcons.CheckCircle, + contentDescription = null, + tint = statusClosedColor, + ) + + Text( + title, + listItemModifier, + style = LocalFontStyles.current.header1, + textAlign = TextAlign.Center, + ) + + Text( + text, + listItemModifier, + textAlign = TextAlign.Center, + ) + + Spacer(Modifier.weight(1f)) + + CrisisCleanupLogoRow(true) + + Spacer(Modifier.weight(1f)) +} + +@Preview +@DayNightPreviews +@Composable +private fun RegisterSuccessViewPreview() { + CrisisCleanupTheme { + Column( + Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + RegisterSuccessView( + title = "Success title", + text = "Very long overflowing message spilling over the extremities", + ) + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 223eb73f3..13c49a7bc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,7 +21,7 @@ androidxHiltNavigationCompose = "1.1.0" androidxLifecycle = "2.6.2" androidxMacroBenchmark = "1.2.2" androidxMetrics = "1.0.0-alpha04" -androidxNavigation = "2.7.5" +androidxNavigation = "2.7.6" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxSecurityCrypto = "1.1.0-alpha06" @@ -61,7 +61,6 @@ playServicesLocation = "21.0.1" playServicesMaps = "18.2.0" protobuf = "3.23.4" protobufPlugin = "0.9.1" -qrCodeKotlin = "4.0.7" retrofit = "2.9.0" retrofitKotlinxSerializationJson = "1.0.0" room = "2.6.1" @@ -69,6 +68,7 @@ secrets = "2.0.1" squareSeismic = "1.0.3" timeAgo = "4.0.3" turbine = "0.13.0" +zxing = "3.5.2" [libraries] accompanist-flowlayout = { group = "com.google.accompanist", name = "accompanist-flowlayout", version.ref = "accompanist" } @@ -157,7 +157,6 @@ playservices-location = { group = "com.google.android.gms", name = "play-service playservices-maps = { group = "com.google.android.gms", name = "play-services-maps", version.ref = "playServicesMaps" } protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } -qrcode-kotlin = { group = "io.github.g0dkar", name = "qrcode-kotlin-jvm", version.ref = "qrCodeKotlin" } retrofit-core = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit-kotlin-serialization = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationJson" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } @@ -167,6 +166,7 @@ room-testing = { group = "androidx.room", name = "room-testing", version.ref = " square-seismic = { group = "com.squareup", name = "seismic", version.ref = "squareSeismic" } timeago = { group = "com.github.marlonlom", name = "timeago", version.ref = "timeAgo" } turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } +zxing = { group = "com.google.zxing", name = "core", version.ref = "zxing" } # Dependencies of the included build-logic android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } diff --git a/lint/build.gradle.kts b/lint/build.gradle.kts index c106f0daa..a7cd37f9c 100644 --- a/lint/build.gradle.kts +++ b/lint/build.gradle.kts @@ -1,4 +1,3 @@ - import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -10,13 +9,13 @@ plugins { java { // Up to Java 11 APIs are available through desugaring // https://developer.android.com/studio/write/java11-minimal-support-table - sourceCompatibility = JavaVersion.VERSION_11 - targetCompatibility = JavaVersion.VERSION_11 + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 } tasks.withType().configureEach { kotlinOptions { - jvmTarget = JavaVersion.VERSION_11.toString() + jvmTarget = JavaVersion.VERSION_17.toString() } } From 3e3ea3885c3bed5b199775d41858205f1550f209 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 19 Dec 2023 15:50:15 -0500 Subject: [PATCH 7/7] Update proguard and bump version --- app/build.gradle.kts | 2 +- app/proguard-rules.pro | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 50ac0c5e2..293da031a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -13,7 +13,7 @@ plugins { android { defaultConfig { - val buildVersion = 172 + val buildVersion = 173 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1ea4feef3..4d19d1c60 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,3 +24,7 @@ # @Serializable and @Polymorphic are used at runtime for polymorphic serialization. -keepattributes RuntimeVisibleAnnotations,AnnotationDefault + +# Android Studio Hedgehog require the following rules +-dontwarn io.grpc.internal.DnsNameResolverProvider +-dontwarn io.grpc.internal.PickFirstLoadBalancerProvider \ No newline at end of file