From e4a74bc2a8e35ffc811ec541fe24873ea024341c Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 7 Aug 2024 10:00:02 -0400 Subject: [PATCH 01/15] Upload crash mapping files --- app/build.gradle.kts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c759cd16..dfa2d7c6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.nowinandroid.android.application.flavors) alias(libs.plugins.nowinandroid.android.application.jacoco) alias(libs.plugins.nowinandroid.android.application.firebase) + alias(libs.plugins.firebase.crashlytics) alias(libs.plugins.nowinandroid.hilt) id("com.google.android.libraries.mapsplatform.secrets-gradle-plugin") } @@ -53,6 +54,7 @@ android { } configure { + mappingFileUploadEnabled = true // Enable processing and uploading of native symbols to Firebase servers. // By default, this is disabled to improve build speeds. // This flag must be enabled to see properly-symbolicated native From 6aa52770feb2f8a00b2b520309afae3a2ac755b9 Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 8 Aug 2024 14:02:18 -0400 Subject: [PATCH 02/15] Add login with phone action to login with email screen --- .../navigation/CrisisCleanupAuthNavHost.kt | 7 +++++ build_sign_prod_release_bundle.sh | 28 ++----------------- .../navigation/LoginWithEmailNavigation.kt | 2 ++ .../authentication/ui/AuthComposables.kt | 15 ++++++---- .../authentication/ui/LoginWithEmailScreen.kt | 21 +++++++++++--- .../authentication/ui/LoginWithPhoneScreen.kt | 6 ++-- 6 files changed, 42 insertions(+), 37 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt index 256b6d25..5f129c68 100644 --- a/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt +++ b/app/src/main/java/com/crisiscleanup/navigation/CrisisCleanupAuthNavHost.kt @@ -52,6 +52,12 @@ fun CrisisCleanupAuthNavHost( val navToVolunteerOrg = navController::navigateToVolunteerOrg val navToForgotPassword = navController::navigateToForgotPassword val navToEmailMagicLink = navController::navigateToEmailLoginLink + val navToLoginWithPhoneClearStack = remember(navController) { + { + navController.popToAuth() + navController.navigateToLoginWithPhone() + } + } NavHost( navController = navController, @@ -66,6 +72,7 @@ fun CrisisCleanupAuthNavHost( closeAuthentication = closeAuthentication, openForgotPassword = navToForgotPassword, openEmailMagicLink = navToEmailMagicLink, + openPhoneLogin = navToLoginWithPhoneClearStack, nestedGraphs = { forgotPasswordScreen( onBack = onBack, diff --git a/build_sign_prod_release_bundle.sh b/build_sign_prod_release_bundle.sh index b34d1b3e..8751aa26 100755 --- a/build_sign_prod_release_bundle.sh +++ b/build_sign_prod_release_bundle.sh @@ -52,48 +52,24 @@ fi $GRADLEW clean $GRADLEW bundleProdRelease -# Copy build (and related) to build dir APP_OUT=$DIR/app/build/outputs if [[ -z "$DIST_DIR" ]]; then DIST_DIR=$DIR/app/build fi DIST_AAB=$DIST_DIR/app-prod-release.aab -MAPPING_FILE_NAME=prod-release-aab-mapping.txt cp $APP_OUT/bundle/prodRelease/app-prod-release.aab $DIST_AAB -cp $APP_OUT/mapping/prodRelease/mapping.txt $DIST_DIR/$MAPPING_FILE_NAME # Sign app bundle jarsigner -verbose -storepass $CRISIS_CLEANUP_ANDROID_KEYSTORE_PW -keystore $CRISIS_CLEANUP_ANDROID_KEYSTORE_PATH $DIST_AAB $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_ALIAS -keypass $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_PW # Most likely successful -if [[ -f "$DIST_AAB" && -f "$DIST_DIR/$MAPPING_FILE_NAME" ]]; then +if [[ -f "$DIST_AAB" ]]; then GREEN='\033[0;32m' NC='\033[0m' dirPathLength=${#DIR} bundleRelativePath=${DIST_AAB:dirPathLength} - echo -e "\n${GREEN}Signed bundle at${NC} .$bundleRelativePath. Mapping $MAPPING_FILE_NAME is copied to same area.\n" + echo -e "\n${GREEN}Signed bundle at${NC} .$bundleRelativePath.\n" else messageExit "Something went wrong during build/signing" fi - -$GRADLEW bundleAussieRelease - -DIST_AAB=$DIST_DIR/app-aussie-release.aab -MAPPING_FILE_NAME=aussie-release-aab-mapping.txt -cp $APP_OUT/bundle/aussieRelease/app-aussie-release.aab $DIST_AAB -cp $APP_OUT/mapping/aussieRelease/mapping.txt $DIST_DIR/$MAPPING_FILE_NAME - -# Sign app bundle -jarsigner -verbose -storepass $CRISIS_CLEANUP_ANDROID_KEYSTORE_PW -keystore $CRISIS_CLEANUP_ANDROID_KEYSTORE_PATH $DIST_AAB $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_ALIAS -keypass $CRISIS_CLEANUP_ANDROID_KEYSTORE_KEY_PW - -# Most likely successful -if [[ -f "$DIST_AAB" && -f "$DIST_DIR/$MAPPING_FILE_NAME" ]]; then - GREEN='\033[0;32m' - NC='\033[0m' - dirPathLength=${#DIR} - bundleRelativePath=${DIST_AAB:dirPathLength} - echo -e "\n${GREEN}Signed bundle at${NC} .$bundleRelativePath. Mapping $MAPPING_FILE_NAME is copied to same area.\n" -else - messageExit "Something went wrong during build/signing" -fi \ 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 1fa95909..24a504cd 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 @@ -23,6 +23,7 @@ fun NavGraphBuilder.loginWithEmailScreen( closeAuthentication: () -> Unit, openForgotPassword: () -> Unit, openEmailMagicLink: () -> Unit, + openPhoneLogin: () -> Unit, ) { composable(route = LOGIN_WITH_EMAIL_ROUTE) { LoginWithEmailRoute( @@ -31,6 +32,7 @@ fun NavGraphBuilder.loginWithEmailScreen( closeAuthentication = closeAuthentication, openForgotPassword = openForgotPassword, openEmailMagicLink = openEmailMagicLink, + openPhoneLogin = openPhoneLogin, ) } nestedGraphs() 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 228b0713..eb93c5d7 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 @@ -2,6 +2,7 @@ package com.crisiscleanup.feature.authentication.ui 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.padding @@ -58,17 +59,21 @@ internal fun LinkAction( Modifier.fillMaxWidth(), horizontalArrangement = arrangement, ) { - Text( - text = translator(textTranslateKey), + Box( modifier = Modifier .clickable( enabled = enabled, onClick = action, ) .then(modifier), - style = LocalFontStyles.current.header4, - color = if (enabled) color else color.disabledAlpha(), - ) + ) { + Text( + text = translator(textTranslateKey), + modifier = Modifier.align(Alignment.CenterEnd), + style = LocalFontStyles.current.header4, + color = if (enabled) color else color.disabledAlpha(), + ) + } } } 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 169dc13a..838169f2 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 @@ -51,6 +51,7 @@ fun LoginWithEmailRoute( closeAuthentication: () -> Unit = {}, openForgotPassword: () -> Unit = {}, openEmailMagicLink: () -> Unit = {}, + openPhoneLogin: () -> Unit = {}, viewModel: AuthenticationViewModel = hiltViewModel(), ) { val onCloseScreen = remember(viewModel, closeAuthentication) { @@ -95,6 +96,7 @@ fun LoginWithEmailRoute( onBack = onBack, openForgotPassword = openForgotPassword, openEmailMagicLink = openEmailMagicLink, + openPhoneLogin = openPhoneLogin, ) Spacer(modifier = Modifier.weight(1f)) } @@ -109,6 +111,7 @@ private fun LoginWithEmailScreen( onBack: () -> Unit = {}, openForgotPassword: () -> Unit = {}, openEmailMagicLink: () -> Unit = {}, + openPhoneLogin: () -> Unit = {}, viewModel: AuthenticationViewModel = hiltViewModel(), ) { val translator = LocalAppTranslator.current @@ -171,19 +174,29 @@ private fun LoginWithEmailScreen( LinkAction( "actions.request_magic_link", Modifier - .testTag("loginRequestMagicLinkAction") + .listItemPadding() .actionHeight() - .listItemPadding(), + .testTag("loginRequestMagicLinkAction"), enabled = isNotBusy, action = openEmailMagicLink, ) + LinkAction( + "loginForm.login_with_cell", + Modifier + .listItemPadding() + .actionHeight() + .testTag("phoneLoginAction"), + enabled = isNotBusy, + action = openPhoneLogin, + ) + LinkAction( "invitationSignup.forgot_password", Modifier - .testTag("loginForgotPasswordAction") + .listItemPadding() .actionHeight() - .listItemPadding(), + .testTag("loginForgotPasswordAction"), enabled = isNotBusy, action = openForgotPassword, ) 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 fef59f8c..6cf41402 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 @@ -45,6 +45,7 @@ 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.TopAppBarCancelAction +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.LocalFontStyles @@ -173,13 +174,13 @@ private fun LoginWithPhoneScreen( onEnter = requestPhoneCode, ) - // TODO Hide if device does not have a SIM/phone number + // TODO Hide if device does not have a SIM/phone number and account number is blank LinkAction( t("loginWithPhone.use_phones_number"), modifier = Modifier .listItemPadding() + .actionHeight() .testTag("phoneLoginRequestPhoneNumber"), - arrangement = Arrangement.End, enabled = true, action = viewModel::requestPhoneNumber, ) @@ -197,6 +198,7 @@ private fun LoginWithPhoneScreen( "actions.back", modifier = Modifier .listItemPadding() + .actionHeight() .testTag("phoneLoginBackAction"), arrangement = Arrangement.Start, enabled = isNotBusy, From 0fe8dca289a26e59adf43408db14251876ebf31b Mon Sep 17 00:00:00 2001 From: hue Date: Thu, 8 Aug 2024 14:50:48 -0400 Subject: [PATCH 03/15] Prepare for saving account phone --- .../data/repository/AccountDataRepository.kt | 1 + .../CrisisCleanupAccountDataRepository.kt | 1 + .../repository/fake/FakeAccountRepository.kt | 1 + .../core/network/fake/FakeAuthApi.kt | 1 + .../core/network/model/NetworkAuth.kt | 1 + .../core/network/model/NetworkUser.kt | 1 + .../authentication/AuthenticationViewModel.kt | 1 + .../authentication/LoginWithPhoneViewModel.kt | 37 ++++++++++--------- .../authentication/MagicLinkLoginViewModel.kt | 37 ++++++++++--------- .../AuthenticationViewModelTest.kt | 3 ++ 10 files changed, 50 insertions(+), 34 deletions(-) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRepository.kt index 3c54fd5f..909cd01c 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AccountDataRepository.kt @@ -26,6 +26,7 @@ interface AccountDataRepository { accessToken: String, id: Long, email: String, + phone: String, firstName: String, lastName: String, expirySeconds: Long, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt index 23406260..cf278392 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/CrisisCleanupAccountDataRepository.kt @@ -66,6 +66,7 @@ class CrisisCleanupAccountDataRepository @Inject constructor( accessToken: String, id: Long, email: String, + phone: String, firstName: String, lastName: String, expirySeconds: Long, diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/fake/FakeAccountRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/fake/FakeAccountRepository.kt index 8058e0ab..087ee8e0 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/fake/FakeAccountRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/fake/FakeAccountRepository.kt @@ -39,6 +39,7 @@ class FakeAccountRepository : AccountDataRepository { accessToken: String, id: Long, email: String, + phone: String, firstName: String, lastName: String, expirySeconds: Long, diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt b/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt index 1cc5aaca..0b8c3cbe 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/fake/FakeAuthApi.kt @@ -18,6 +18,7 @@ class FakeAuthApi @Inject constructor() : CrisisCleanupAuthApi { claims = NetworkAuthUserClaims( id = 1, email = "demo@crisiscleanup.org", + mobile = "1234567890", firstName = "Demo", lastName = "User", files = emptyList(), diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt index d231c171..cf2f416e 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkAuth.kt @@ -25,6 +25,7 @@ data class NetworkAuthUserClaims( // UPDATE NetworkAuthTest in conjunction with changes here val id: Long, val email: String, + val mobile: String, @SerialName("first_name") val firstName: String, @SerialName("last_name") 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 bbbfe07b..510412f7 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 @@ -19,6 +19,7 @@ data class NetworkUser( data class NetworkUserProfile( val id: Long, val email: String, + val mobile: String, @SerialName("first_name") val firstName: String, @SerialName("last_name") 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 696da3e7..880f6c5e 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 @@ -175,6 +175,7 @@ class AuthenticationViewModel @Inject constructor( accessToken = accessToken, id = claims.id, email = claims.email, + phone = claims.mobile, firstName = claims.firstName, lastName = claims.lastName, expirySeconds = expirySeconds, 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 02a17d0d..bb268740 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 @@ -307,23 +307,26 @@ class LoginWithPhoneViewModel @Inject constructor( val expirySeconds = Clock.System.now() .plus(tokens.expiresIn!!.seconds).epochSeconds - accountDataRepository.setAccount( - refreshToken = tokens.refreshToken!!, - accessToken = tokens.accessToken!!, - id = accountProfile.id, - email = accountProfile.email, - firstName = accountProfile.firstName, - lastName = accountProfile.lastName, - expirySeconds = expirySeconds, - profilePictureUri = accountProfile.profilePicUrl ?: "", - org = OrgData( - id = accountProfile.organization.id, - name = accountProfile.organization.name, - ), - hasAcceptedTerms = accountProfile.hasAcceptedTerms == true, - approvedIncidentIds = accountProfile.approvedIncidents, - activeRoles = accountProfile.activeRoles, - ) + with(accountProfile) { + accountDataRepository.setAccount( + refreshToken = tokens.refreshToken!!, + accessToken = tokens.accessToken!!, + id = id, + email = email, + phone = mobile, + firstName = firstName, + lastName = lastName, + expirySeconds = expirySeconds, + profilePictureUri = profilePicUrl ?: "", + org = OrgData( + id = organization.id, + name = organization.name, + ), + hasAcceptedTerms = hasAcceptedTerms == true, + approvedIncidentIds = approvedIncidents, + activeRoles = activeRoles, + ) + } isSuccessful = 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 e1ed929e..ec86b115 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 @@ -65,23 +65,26 @@ class MagicLinkLoginViewModel @Inject constructor( } else { val expirySeconds = Clock.System.now().plus(expiresIn.seconds).epochSeconds - accountDataRepository.setAccount( - refreshToken = refreshToken, - accessToken = accessToken, - id = accountProfile.id, - email = accountProfile.email, - firstName = accountProfile.firstName, - lastName = accountProfile.lastName, - expirySeconds = expirySeconds, - profilePictureUri = accountProfile.profilePicUrl ?: "", - org = OrgData( - id = accountProfile.organization.id, - name = accountProfile.organization.name, - ), - hasAcceptedTerms = accountProfile.hasAcceptedTerms == true, - approvedIncidentIds = accountProfile.approvedIncidents, - activeRoles = accountProfile.activeRoles, - ) + with(accountProfile) { + accountDataRepository.setAccount( + refreshToken = refreshToken, + accessToken = accessToken, + id = id, + email = email, + phone = mobile, + firstName = firstName, + lastName = lastName, + expirySeconds = expirySeconds, + profilePictureUri = profilePicUrl ?: "", + org = OrgData( + id = organization.id, + name = organization.name, + ), + hasAcceptedTerms = hasAcceptedTerms == true, + approvedIncidentIds = approvedIncidents, + activeRoles = activeRoles, + ) + } isAuthenticateSuccessful.value = true } diff --git a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt index 885ac738..40d171cf 100644 --- a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt +++ b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt @@ -107,6 +107,7 @@ class AuthenticationViewModelTest { id = any(), accessToken = any(), email = any(), + phone = any(), firstName = any(), lastName = any(), expirySeconds = any(), @@ -235,6 +236,7 @@ class AuthenticationViewModelTest { claims = NetworkAuthUserClaims( id = 534, email = "email@address.com", + mobile = "9876543210", firstName = "first-name", lastName = "last-name", approvedIncidents = setOf(53), @@ -260,6 +262,7 @@ class AuthenticationViewModelTest { refreshToken = "refresh-token", accessToken = "access-token", email = "email@address.com", + phone = "9876543210", firstName = "first-name", lastName = "last-name", expirySeconds = match { seconds -> abs(seconds - nowSeconds) < 864000L + 1000 }, From 8e0f58cafbb45cdfa12d9ba54452530d56541a56 Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 10 Aug 2024 16:48:05 -0400 Subject: [PATCH 04/15] Add retrofit proguard --- app/build.gradle.kts | 3 ++- app/proguard-retrofit2.pro | 48 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 app/proguard-retrofit2.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index dfa2d7c6..f741bd78 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 214 + val buildVersion = 216 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" @@ -43,6 +43,7 @@ android { getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro", "proguard-playservices.pro", + "proguard-retrofit2.pro", "proguard-crashlytics.pro", ) diff --git a/app/proguard-retrofit2.pro b/app/proguard-retrofit2.pro new file mode 100644 index 00000000..6c1daaf0 --- /dev/null +++ b/app/proguard-retrofit2.pro @@ -0,0 +1,48 @@ +# Retrofit does reflection on generic parameters. InnerClasses is required to use Signature and +# EnclosingMethod is required to use InnerClasses. +-keepattributes Signature, InnerClasses, EnclosingMethod + +# Retrofit does reflection on method and parameter annotations. +-keepattributes RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations + +# Keep annotation default values (e.g., retrofit2.http.Field.encoded). +-keepattributes AnnotationDefault + +# Retain service method parameters when optimizing. +-keepclassmembers,allowshrinking,allowobfuscation interface * { + @retrofit2.http.* ; +} + +# Ignore annotation used for build tooling. +-dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement + +# Ignore JSR 305 annotations for embedding nullability information. +-dontwarn javax.annotation.** + +# Guarded by a NoClassDefFoundError try/catch and only used when on the classpath. +-dontwarn kotlin.Unit + +# Top-level functions that can only be used by Kotlin. +-dontwarn retrofit2.KotlinExtensions +-dontwarn retrofit2.KotlinExtensions$* + +# With R8 full mode, it sees no subtypes of Retrofit interfaces since they are created with a Proxy +# and replaces all potential values with null. Explicitly keeping the interfaces prevents this. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface <1> + +# Keep inherited services. +-if interface * { @retrofit2.http.* ; } +-keep,allowobfuscation interface * extends <1> + +# With R8 full mode generic signatures are stripped for classes that are not +# kept. Suspend functions are wrapped in continuations where the type argument +# is used. +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +# R8 full mode strips generic signatures from return types if not kept. +-if interface * { @retrofit2.http.* public *** *(...); } +-keep,allowoptimization,allowshrinking,allowobfuscation class <3> + +# With R8 full mode generic signatures are stripped for classes that are not kept. +-keep,allowobfuscation,allowshrinking class retrofit2.Response \ No newline at end of file From 52535a23aaf67b958e3040e0a04d6ffcd8d40a9c Mon Sep 17 00:00:00 2001 From: hue Date: Sat, 10 Aug 2024 17:05:34 -0400 Subject: [PATCH 05/15] Start on menu tutorial --- .../crisiscleanup/MainActivityViewModel.kt | 18 ++++ .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 33 ++++++- .../com/crisiscleanup/ui/TutorialGraphics.kt | 94 +++++++++++++++++++ .../core/common/TutorialDirector.kt | 30 ++++++ .../core/ui/LayoutSizePosition.kt | 14 +++ .../feature/menu/MenuTutorialDirector.kt | 32 +++++++ .../feature/menu/MenuViewModel.kt | 4 + .../feature/menu/di/MenuModule.kt | 22 +++++ .../feature/menu/navigation/MenuNavigation.kt | 2 +- .../feature/menu/{ => ui}/MenuScreen.kt | 35 ++++++- gradle/libs.versions.toml | 6 +- 11 files changed, 280 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt create mode 100644 core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt create mode 100644 core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt create mode 100644 feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt create mode 100644 feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt rename feature/menu/src/main/java/com/crisiscleanup/feature/menu/{ => ui}/MenuScreen.kt (90%) diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 2a47c593..5d3bcc53 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -8,8 +8,11 @@ import androidx.lifecycle.viewModelScope import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.AppSettingsProvider import com.crisiscleanup.core.common.AppVersionProvider +import com.crisiscleanup.core.common.CrisisCleanupTutorialDirectors.Menu import com.crisiscleanup.core.common.KeyResourceTranslator import com.crisiscleanup.core.common.NetworkMonitor +import com.crisiscleanup.core.common.TutorialDirector +import com.crisiscleanup.core.common.Tutorials import com.crisiscleanup.core.common.event.AuthEventBus import com.crisiscleanup.core.common.event.ExternalEventBus import com.crisiscleanup.core.common.event.UserPersistentInvite @@ -67,6 +70,7 @@ class MainActivityViewModel @Inject constructor( appDataRepository: AppDataManagementRepository, private val accountDataRefresher: AccountDataRefresher, private val accountUpdateRepository: AccountUpdateRepository, + @Tutorials(Menu) private val menuTutorialDirector: TutorialDirector, val translator: KeyResourceTranslator, private val syncPuller: SyncPuller, private val appVersionProvider: AppVersionProvider, @@ -188,6 +192,8 @@ class MainActivityViewModel @Inject constructor( val isSwitchingToProduction: StateFlow val productionSwitchMessage: StateFlow + val menuTutorialStep = menuTutorialDirector.tutorialStep + init { accountDataRepository.accountData .onEach { @@ -307,6 +313,18 @@ class MainActivityViewModel @Inject constructor( } } } + + fun startMenuTutorial() { + menuTutorialDirector.startTutorial() + } + + fun onMenuTutorialNext() { + menuTutorialDirector.onNextStep() + } + + fun closeMenuTutorial() { + menuTutorialDirector.skipTutorial() + } } sealed interface MainActivityViewState { diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index e87b94b3..0300d095 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -39,6 +39,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.testTagsAsResourceId @@ -50,6 +51,7 @@ import com.crisiscleanup.AuthState import com.crisiscleanup.MainActivityViewModel import com.crisiscleanup.MainActivityViewState import com.crisiscleanup.core.common.NetworkMonitor +import com.crisiscleanup.core.common.TutorialStep import com.crisiscleanup.core.designsystem.LayoutProvider import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.LocalLayoutProvider @@ -57,8 +59,10 @@ import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCen import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.theme.LocalDimensions import com.crisiscleanup.core.ui.AppLayoutArea +import com.crisiscleanup.core.ui.LayoutSizePosition import com.crisiscleanup.core.ui.LocalAppLayout import com.crisiscleanup.core.ui.rememberIsKeyboardOpen +import com.crisiscleanup.core.ui.sizePosition import com.crisiscleanup.feature.authentication.navigation.navigateToMagicLinkLogin import com.crisiscleanup.feature.authentication.navigation.navigateToOrgPersistentInvite import com.crisiscleanup.feature.authentication.navigation.navigateToPasswordReset @@ -209,10 +213,14 @@ private fun BoxScope.LoadedContent( ?: true val isOnboarding = !hideOnboarding + val menuTutorialStep by viewModel.menuTutorialStep.collectAsStateWithLifecycle() + NavigableContent( snackbarHostState, appState, isOnboarding, + menuTutorialStep, + viewModel::onMenuTutorialNext, ) { openAuthentication = true } if ( @@ -315,11 +323,19 @@ private fun NavigableContent( snackbarHostState: SnackbarHostState, appState: CrisisCleanupAppState, isOnboarding: Boolean, + menuTutorialStep: TutorialStep, + advanceMenuTutorial: () -> Unit, openAuthentication: () -> Unit, ) { val showNavigation = appState.isTopLevelRoute val layoutBottomNav = LocalLayoutProvider.current.isBottomNav val isFullscreen = appState.isFullscreenRoute + + var navBarSizePosition by remember { mutableStateOf(LayoutSizePosition()) } + val navBarSizePositionModifier = Modifier.onGloballyPositioned { coordinates -> + navBarSizePosition = coordinates.sizePosition + } + Scaffold( modifier = Modifier.semantics { testTagsAsResourceId = true @@ -337,7 +353,7 @@ private fun NavigableContent( ) { AppNavigationBar( appState, - Modifier.testTag("AppNavigationBottomBar"), + navBarSizePositionModifier.testTag("AppNavigationBottomBar"), ) } @@ -367,9 +383,9 @@ private fun NavigableContent( if (showNavigation && !layoutBottomNav) { AppNavigationBar( appState, - Modifier - .testTag("AppNavigationSideRail") - .safeDrawingPadding(), + navBarSizePositionModifier + .safeDrawingPadding() + .testTag("AppNavigationSideRail"), true, ) } @@ -406,6 +422,14 @@ private fun NavigableContent( } } } + + if (menuTutorialStep != TutorialStep.End) { + TutorialOverlay( + tutorialStep = menuTutorialStep, + onNextStep = advanceMenuTutorial, + navBarSizePosition = navBarSizePosition, + ) + } } @Composable @@ -430,3 +454,4 @@ private fun ExpiredAccountAlert( } } } + diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt new file mode 100644 index 00000000..48d7b0a4 --- /dev/null +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -0,0 +1,94 @@ +package com.crisiscleanup.ui + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Color.Companion.Black +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.graphicsLayer +import com.crisiscleanup.core.common.TutorialStep +import com.crisiscleanup.core.ui.LayoutSizePosition + +@Composable +internal fun TutorialOverlay( + tutorialStep: TutorialStep, + onNextStep: () -> Unit, + navBarSizePosition: LayoutSizePosition, +) { + // TODO Test recomposing/caching + // TODO Animate/morph between steps + Canvas( + modifier = Modifier + .fillMaxSize() + .clickable( + onClick = onNextStep, + ) + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + }, + ) { + drawRect( + color = Black.copy(alpha = 0.8f), + size = size, + ) + + when (tutorialStep) { + TutorialStep.MenuStart, + TutorialStep.AppNavBar, + -> + menuTutorialAppNav( + getNavBarSpotlightSizeOffset( + size, + navBarSizePosition, + ), + ) + + else -> {} + } + } +} + +private fun DrawScope.menuTutorialAppNav( + sizeOffset: SizeOffset, +) { + drawRoundRect( + color = Color.White, + topLeft = sizeOffset.topLeft, + size = sizeOffset.size, + cornerRadius = CornerRadius(sizeOffset.size.height * 0.5f), + blendMode = BlendMode.Clear, + ) +} + +private data class SizeOffset( + val size: Size = Size.Zero, + val topLeft: Offset = Offset.Zero, +) + +private fun getNavBarSpotlightSizeOffset( + viewSize: Size, + navBarSizePosition: LayoutSizePosition, +): SizeOffset { + val navBarSize = navBarSizePosition.size + val isBarScreenWidth = navBarSize.width > viewSize.width * 0.5f + val isBarScreenHeight = navBarSize.height > viewSize.height * 0.5 + + val spotlightWidth = navBarSize.width * 0.85f + val spotlightHeight = navBarSize.height * (if (isBarScreenHeight) 0.95f else 0.5f) + val spotlightSize = Size(spotlightWidth, spotlightHeight) + val horizontalOffset = 0 + val verticalOffset = if (isBarScreenWidth) -64 else 0 + val spotlightTopLeft = Offset( + navBarSizePosition.position.x + (navBarSize.width - spotlightSize.width) * 0.5f + horizontalOffset, + navBarSizePosition.position.y + (navBarSize.height - spotlightSize.height) * 0.5f + verticalOffset, + ) + return SizeOffset(spotlightSize, spotlightTopLeft) +} \ No newline at end of file diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt new file mode 100644 index 00000000..faece262 --- /dev/null +++ b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt @@ -0,0 +1,30 @@ +package com.crisiscleanup.core.common + +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Qualifier +import kotlin.annotation.AnnotationRetention.RUNTIME + +interface TutorialDirector { + val tutorialStep: StateFlow + + fun startTutorial() + fun onNextStep() + fun skipTutorial() +} + +enum class TutorialStep { + MenuStart, + AppNavBar, + AccountInfo, + ProvideAppFeedback, + IncidentSelect, + End +} + +@Qualifier +@Retention(RUNTIME) +annotation class Tutorials(val director: CrisisCleanupTutorialDirectors) + +enum class CrisisCleanupTutorialDirectors { + Menu +} diff --git a/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt b/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt new file mode 100644 index 00000000..d748a09d --- /dev/null +++ b/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt @@ -0,0 +1,14 @@ +package com.crisiscleanup.core.ui + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.layout.LayoutCoordinates +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.unit.IntSize + +data class LayoutSizePosition( + val size: IntSize = IntSize.Zero, + val position: Offset = Offset.Zero, +) + +val LayoutCoordinates.sizePosition: LayoutSizePosition + get() = LayoutSizePosition(size, positionInRoot()) \ No newline at end of file diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt new file mode 100644 index 00000000..8db18d72 --- /dev/null +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -0,0 +1,32 @@ +package com.crisiscleanup.feature.menu + +import com.crisiscleanup.core.common.TutorialDirector +import com.crisiscleanup.core.common.TutorialStep +import kotlinx.coroutines.flow.MutableStateFlow +import javax.inject.Inject + +class MenuTutorialDirector @Inject constructor() : TutorialDirector { + private val stepFLow = MutableStateFlow(TutorialStep.End) + override val tutorialStep = stepFLow + + override fun startTutorial() { + tutorialStep.value = TutorialStep.MenuStart + } + + override fun skipTutorial() { + tutorialStep.value = TutorialStep.End + } + + override fun onNextStep() { + val nextStep = when (tutorialStep.value) { + TutorialStep.MenuStart, + TutorialStep.AppNavBar, + -> TutorialStep.End + + // TODO Other steps + + else -> TutorialStep.End + } + tutorialStep.value = nextStep + } +} \ No newline at end of file diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index a01cc78c..a2a15635 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -6,8 +6,11 @@ import com.crisiscleanup.core.appcomponent.AppTopBarDataProvider import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.AppSettingsProvider import com.crisiscleanup.core.common.AppVersionProvider +import com.crisiscleanup.core.common.CrisisCleanupTutorialDirectors.Menu import com.crisiscleanup.core.common.DatabaseVersionProvider import com.crisiscleanup.core.common.KeyResourceTranslator +import com.crisiscleanup.core.common.TutorialDirector +import com.crisiscleanup.core.common.Tutorials import com.crisiscleanup.core.common.di.ApplicationScope import com.crisiscleanup.core.common.network.CrisisCleanupDispatchers.IO import com.crisiscleanup.core.common.network.Dispatcher @@ -42,6 +45,7 @@ class MenuViewModel @Inject constructor( appSettingsProvider: AppSettingsProvider, private val appEnv: AppEnv, private val syncPuller: SyncPuller, + @Tutorials(Menu) val menuTutorialDirector: TutorialDirector, private val databaseVersionProvider: DatabaseVersionProvider, translator: KeyResourceTranslator, @ApplicationScope private val externalScope: CoroutineScope, diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt new file mode 100644 index 00000000..17bce71c --- /dev/null +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup.feature.menu.di + +import com.crisiscleanup.core.common.CrisisCleanupTutorialDirectors +import com.crisiscleanup.core.common.TutorialDirector +import com.crisiscleanup.core.common.Tutorials +import com.crisiscleanup.feature.menu.MenuTutorialDirector +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +interface DataModule { + @Singleton + @Binds + @Tutorials(CrisisCleanupTutorialDirectors.Menu) + fun bindsTutorialDirector( + director: MenuTutorialDirector, + ): TutorialDirector +} \ No newline at end of file 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 9d6ee66a..8fb00d6a 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 @@ -5,7 +5,7 @@ import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable import com.crisiscleanup.core.appnav.RouteConstant.MENU_ROUTE -import com.crisiscleanup.feature.menu.MenuRoute +import com.crisiscleanup.feature.menu.ui.MenuRoute fun NavController.navigateToMenu(navOptions: NavOptions? = null) { this.navigate(MENU_ROUTE, navOptions) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt similarity index 90% rename from feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt rename to feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index 299301d1..c8a48227 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -1,4 +1,4 @@ -package com.crisiscleanup.feature.menu +package com.crisiscleanup.feature.menu.ui import androidx.compose.foundation.Image import androidx.compose.foundation.clickable @@ -53,6 +53,8 @@ import com.crisiscleanup.core.designsystem.theme.neutralFontColor import com.crisiscleanup.core.designsystem.theme.primaryBlueColor import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.selectincident.SelectIncidentDialog +import com.crisiscleanup.feature.menu.MenuViewModel +import com.crisiscleanup.feature.menu.R @Composable internal fun MenuRoute( @@ -113,12 +115,41 @@ private fun MenuScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { + Row( + listItemModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = t("~~Open tutorial"), + modifier = Modifier + .clickable( + onClick = viewModel.menuTutorialDirector::startTutorial, + ) + .listItemPadding(), + style = LocalFontStyles.current.header3, + color = primaryBlueColor, + ) + Spacer(Modifier.weight(1f)) + Text( + text = t("actions.done"), + modifier = Modifier + .clickable( + onClick = { + // TODO Move to bottom of screen + }, + ) + .listItemPadding(), + style = LocalFontStyles.current.header4, + color = primaryBlueColor, + ) + } + val hideGettingStartedVideo = remember(viewModel) { { viewModel.showGettingStartedVideo(false) } } GettingStartedSection( menuItemVisibility.showGettingStartedVideo, - hideGettingStartedVideo, + hideGettingStartedVideo = hideGettingStartedVideo, viewModel.gettingStartedVideoUrl, viewModel.isNotProduction, toggleGettingStartedSection = viewModel::showGettingStartedVideo, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f43adff7..5153db2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,7 +24,7 @@ androidxLifecycle = "2.8.4" androidxMacroBenchmark = "1.2.4" androidxMetrics = "1.0.0-beta01" androidxNavigation = "2.7.7" -androidxPaging = "3.3.1" +androidxPaging = "3.3.2" androidxProfileinstaller = "1.3.1" androidxStartup = "1.1.1" androidxSecurityCrypto = "1.1.0-alpha06" @@ -35,7 +35,7 @@ androidxTestRunner = "1.6.1" androidxTracing = "1.2.0" androidxUiAutomator = "2.3.0" androidxWindowManager = "1.3.0" -androidxWork = "2.9.0" +androidxWork = "2.9.1" apacheCommonsText = "1.10.0" coil = "2.6.0" dependencyGuard = "0.5.0" @@ -56,7 +56,7 @@ kotlinxCoroutinesPlayServices = "1.8.0" kotlinxDatetime = "0.5.0" kotlinxSerializationJson = "1.6.3" ksp = "2.0.0-1.0.21" -mlkitBarcodeScanning = "17.2.0" +mlkitBarcodeScanning = "17.3.0" mockk = "1.13.5" moduleGraph = "2.5.0" okhttp = "4.12.0" From 681b96db08881c0c1c153e7bd090f9ae0535223d Mon Sep 17 00:00:00 2001 From: hue Date: Mon, 12 Aug 2024 17:26:37 -0400 Subject: [PATCH 06/15] Bump version --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f741bd78..f09e524d 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 216 + val buildVersion = 220 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" From 36c026a26833e8c0a7f7cc34196aac3b92529ebf Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 13:55:53 -0400 Subject: [PATCH 07/15] Add tutorial overlay for app nav bar --- .../com/crisiscleanup/ui/TutorialGraphics.kt | 135 ++++++++++++++++-- 1 file changed, 122 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index 48d7b0a4..4abe8eea 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -12,9 +12,18 @@ import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color.Companion.Black import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.DrawScope import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.TextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.drawText +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.sp import com.crisiscleanup.core.common.TutorialStep +import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.ui.LayoutSizePosition @Composable @@ -23,6 +32,11 @@ internal fun TutorialOverlay( onNextStep: () -> Unit, navBarSizePosition: LayoutSizePosition, ) { + val t = LocalAppTranslator.current + val textMeasurer = rememberTextMeasurer() + + val stepForwardText = t("~~(Press anywhere on screen to continue tutorial)") + // TODO Test recomposing/caching // TODO Animate/morph between steps Canvas( @@ -40,25 +54,122 @@ internal fun TutorialOverlay( size = size, ) + val navBarSize = navBarSizePosition.size + val isHorizontalBar = navBarSize.width > size.width * 0.5f when (tutorialStep) { TutorialStep.MenuStart, TutorialStep.AppNavBar, - -> + -> { + val navBarSizeOffset = getNavBarSpotlightSizeOffset( + isHorizontalBar, + navBarSizePosition, + ) + + val stepForwardOffset = if (isHorizontalBar) { + Offset( + navBarSizeOffset.topLeft.x, + (size.height - navBarSizeOffset.size.height) * 0.3f, + ) + } else { + Offset( + size.width * 0.3f, + size.height * 0.3f, + ) + } + drawStepForwardText( + textMeasurer, + stepForwardText, + stepForwardOffset, + ) + + val stepInstruction = t("~~Use the navigation tabs to start Working") menuTutorialAppNav( - getNavBarSpotlightSizeOffset( - size, - navBarSizePosition, - ), + textMeasurer, + isHorizontalBar, + navBarSizeOffset, + stepInstruction, ) + } else -> {} } } } +private fun DrawScope.drawStepForwardText( + textMeasurer: TextMeasurer, + text: String, + offset: Offset, +) { + drawText( + textMeasurer = textMeasurer, + text = text, + topLeft = offset, + style = TextStyle( + fontSize = 16.sp, + color = Color.White, + ), + overflow = TextOverflow.Visible, + ) +} + private fun DrawScope.menuTutorialAppNav( + textMeasurer: TextMeasurer, + isHorizontalBar: Boolean, sizeOffset: SizeOffset, + stepInstruction: String, ) { + val instructionOffset = if (isHorizontalBar) { + Offset( + sizeOffset.topLeft.x, + (size.height - sizeOffset.size.height) * 0.5f, + ) + } else { + Offset( + sizeOffset.size.width + size.width * 0.25f, + size.height * 0.5f, + ) + } + val instructionStyle = TextStyle( + fontSize = 32.sp, + color = Color.White, + ) + drawText( + textMeasurer = textMeasurer, + text = stepInstruction, + topLeft = instructionOffset, + style = instructionStyle, + overflow = TextOverflow.Visible, + ) + + val instructionConstraints = Constraints( + maxWidth = (size.width - sizeOffset.topLeft.x * 2).toInt(), + ) + val textLayout = textMeasurer.measure( + stepInstruction, + instructionStyle, + overflow = TextOverflow.Visible, + constraints = instructionConstraints, + ) + val textSize = textLayout.size + val lineStart = if (isHorizontalBar) { + Offset(size.width * 0.5f, instructionOffset.y + textSize.height + 16) + } else { + Offset(instructionOffset.x - 32, instructionOffset.y + textSize.height * 0.5f) + } + val lineEnd = if (isHorizontalBar) { + Offset(size.width * 0.33f, sizeOffset.topLeft.y - 32) + } else { + Offset(sizeOffset.size.width + 64, size.height * 0.5f) + } + drawLine( + Color.White, + lineStart, + lineEnd, + strokeWidth = 16f, + cap = StrokeCap.Round, + ) + drawRoundRect( color = Color.White, topLeft = sizeOffset.topLeft, @@ -74,21 +185,19 @@ private data class SizeOffset( ) private fun getNavBarSpotlightSizeOffset( - viewSize: Size, + isHorizontalBar: Boolean, navBarSizePosition: LayoutSizePosition, ): SizeOffset { val navBarSize = navBarSizePosition.size - val isBarScreenWidth = navBarSize.width > viewSize.width * 0.5f - val isBarScreenHeight = navBarSize.height > viewSize.height * 0.5 - val spotlightWidth = navBarSize.width * 0.85f - val spotlightHeight = navBarSize.height * (if (isBarScreenHeight) 0.95f else 0.5f) + val spotlightHeight = navBarSize.height * (if (isHorizontalBar) 0.5f else 0.95f) val spotlightSize = Size(spotlightWidth, spotlightHeight) val horizontalOffset = 0 - val verticalOffset = if (isBarScreenWidth) -64 else 0 + val verticalOffset = if (isHorizontalBar) -64 else 0 + val navBarPosition = navBarSizePosition.position val spotlightTopLeft = Offset( - navBarSizePosition.position.x + (navBarSize.width - spotlightSize.width) * 0.5f + horizontalOffset, - navBarSizePosition.position.y + (navBarSize.height - spotlightSize.height) * 0.5f + verticalOffset, + navBarPosition.x + (navBarSize.width - spotlightSize.width) * 0.5f + horizontalOffset, + navBarPosition.y + (navBarSize.height - spotlightSize.height) * 0.5f + verticalOffset, ) return SizeOffset(spotlightSize, spotlightTopLeft) } \ No newline at end of file From 38c3fb88f637b6de45f549d4b3775d80127ecc15 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 14:18:44 -0400 Subject: [PATCH 08/15] Add state for tracking menu tutorial viewing --- .../crisiscleanup/MainActivityViewModel.kt | 17 ++-- .../core/common/TutorialDirector.kt | 7 +- .../repository/AppPreferencesRepository.kt | 3 + .../LocalAppPreferencesRepository.kt | 2 + .../AppPreferencesRepositoryTest.kt | 1 + .../core/data/user_preferences.proto | 1 + .../LocalAppPreferencesDataSource.kt | 9 ++ .../crisiscleanup/core/model/data/UserData.kt | 1 + .../TestLocalAppPreferencesRepository.kt | 7 ++ .../feature/menu/MenuTutorialDirector.kt | 3 +- .../feature/menu/MenuViewModel.kt | 10 +++ .../feature/menu/ui/MenuScreen.kt | 83 +++++++++++++------ 12 files changed, 108 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 5d3bcc53..609163fa 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -63,7 +63,7 @@ import kotlin.time.Duration.Companion.hours @HiltViewModel class MainActivityViewModel @Inject constructor( - localAppPreferencesRepository: LocalAppPreferencesRepository, + private val appPreferencesRepository: LocalAppPreferencesRepository, private val appMetricsRepository: LocalAppMetricsRepository, accountDataRepository: AccountDataRepository, incidentSelector: IncidentSelector, @@ -91,7 +91,7 @@ class MainActivityViewModel @Inject constructor( private val initialAppOpen = AtomicReference(null) val viewState = combine( - localAppPreferencesRepository.userPreferences, + appPreferencesRepository.userPreferences, appMetricsRepository.metrics.distinctUntilChanged(), ::Pair, ) @@ -222,7 +222,7 @@ class MainActivityViewModel @Inject constructor( .flowOn(ioDispatcher) .launchIn(viewModelScope) - localAppPreferencesRepository.userPreferences.onEach { + appPreferencesRepository.userPreferences.onEach { firebaseAnalytics.setAnalyticsCollectionEnabled(it.allowAllAnalytics) } .launchIn(viewModelScope) @@ -314,12 +314,13 @@ class MainActivityViewModel @Inject constructor( } } - fun startMenuTutorial() { - menuTutorialDirector.startTutorial() - } - fun onMenuTutorialNext() { - menuTutorialDirector.onNextStep() + val hasNextStep = menuTutorialDirector.onNextStep() + if (!hasNextStep) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setMenuTutorialDone(true) + } + } } fun closeMenuTutorial() { diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt index faece262..7c7c2a8d 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt @@ -8,7 +8,12 @@ interface TutorialDirector { val tutorialStep: StateFlow fun startTutorial() - fun onNextStep() + + /** + * @return TRUE when there is a next step or FALSE when the tutorial is over + */ + fun onNextStep(): Boolean + fun skipTutorial() } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt index 8df975d7..138aa2a2 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/AppPreferencesRepository.kt @@ -46,6 +46,9 @@ class AppPreferencesRepository @Inject constructor( override suspend fun setHideGettingStartedVideo(hide: Boolean) = preferencesDataSource.setHideGettingStartedVideo(hide) + override suspend fun setMenuTutorialDone(isDone: Boolean) = + preferencesDataSource.setMenuTutorialDone(isDone) + override suspend fun setSelectedIncident(id: Long) = preferencesDataSource.setSelectedIncident(id) diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt index 4646e999..c00a5216 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/LocalAppPreferencesRepository.kt @@ -20,6 +20,8 @@ interface LocalAppPreferencesRepository { suspend fun setHideGettingStartedVideo(hide: Boolean) + suspend fun setMenuTutorialDone(isDone: Boolean) + /** * Caches ID of selected incident. */ diff --git a/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt b/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt index f642bde7..aec6275c 100644 --- a/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt +++ b/core/data/src/test/java/com/crisiscleanup/core/data/repository/AppPreferencesRepositoryTest.kt @@ -63,6 +63,7 @@ class AppPreferencesRepositoryTest { tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, hideGettingStartedVideo = false, + isMenuTutorialDone = false, ), repository.userPreferences.first(), ) diff --git a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto index 639b8780..fb512435 100644 --- a/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto +++ b/core/datastore-proto/src/main/proto/com/crisiscleanup/core/data/user_preferences.proto @@ -33,4 +33,5 @@ message UserPreferences { bool allow_all_analytics = 9; bool hide_getting_started_video = 10; + bool is_menu_tutorial_done = 11; } diff --git a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt index d133ac06..97b6d6d9 100644 --- a/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt +++ b/core/datastore/src/main/java/com/crisiscleanup/core/datastore/LocalAppPreferencesDataSource.kt @@ -50,6 +50,8 @@ class LocalAppPreferencesDataSource @Inject constructor( allowAllAnalytics = it.allowAllAnalytics, hideGettingStartedVideo = it.hideGettingStartedVideo, + + isMenuTutorialDone = it.isMenuTutorialDone, ) } @@ -64,6 +66,7 @@ class LocalAppPreferencesDataSource @Inject constructor( tableViewSortBy = WorksiteSortBy.None.literal allowAllAnalytics = false hideGettingStartedVideo = false + isMenuTutorialDone = false } } } @@ -148,4 +151,10 @@ class LocalAppPreferencesDataSource @Inject constructor( it.copy { hideGettingStartedVideo = hide } } } + + suspend fun setMenuTutorialDone(isDone: Boolean) { + userPreferences.updateData { + it.copy { isMenuTutorialDone = isDone } + } + } } diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt index 03831ace..ad490b58 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/UserData.kt @@ -19,4 +19,5 @@ data class UserData( val allowAllAnalytics: Boolean, val hideGettingStartedVideo: Boolean, + val isMenuTutorialDone: Boolean, ) diff --git a/core/testing/src/main/java/com/crisiscleanup/core/testing/repository/TestLocalAppPreferencesRepository.kt b/core/testing/src/main/java/com/crisiscleanup/core/testing/repository/TestLocalAppPreferencesRepository.kt index e8bd1e20..22619a1f 100644 --- a/core/testing/src/main/java/com/crisiscleanup/core/testing/repository/TestLocalAppPreferencesRepository.kt +++ b/core/testing/src/main/java/com/crisiscleanup/core/testing/repository/TestLocalAppPreferencesRepository.kt @@ -19,6 +19,7 @@ private val emptyUserData = UserData( tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, hideGettingStartedVideo = false, + isMenuTutorialDone = false, ) class TestLocalAppPreferencesRepository : LocalAppPreferencesRepository { @@ -47,6 +48,12 @@ class TestLocalAppPreferencesRepository : LocalAppPreferencesRepository { } } + override suspend fun setMenuTutorialDone(isDone: Boolean) { + currentUserData.let { current -> + userDataInternal.tryEmit(current.copy(isMenuTutorialDone = isDone)) + } + } + override suspend fun setSelectedIncident(id: Long) { currentUserData.let { current -> userDataInternal.tryEmit(current.copy(selectedIncidentId = id)) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt index 8db18d72..4caaac59 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -17,7 +17,7 @@ class MenuTutorialDirector @Inject constructor() : TutorialDirector { tutorialStep.value = TutorialStep.End } - override fun onNextStep() { + override fun onNextStep(): Boolean { val nextStep = when (tutorialStep.value) { TutorialStep.MenuStart, TutorialStep.AppNavBar, @@ -28,5 +28,6 @@ class MenuTutorialDirector @Inject constructor() : TutorialDirector { else -> TutorialStep.End } tutorialStep.value = nextStep + return nextStep != TutorialStep.End } } \ No newline at end of file diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index a2a15635..e3c8805b 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -97,6 +97,10 @@ class MenuViewModel @Inject constructor( started = SharingStarted.WhileSubscribed(), ) + val isMenuTutorialDone = appPreferencesRepository.userPreferences.map { + it.isMenuTutorialDone + } + init { externalScope.launch(ioDispatcher) { syncLogRepository.trimOldLogs() @@ -144,6 +148,12 @@ class MenuViewModel @Inject constructor( appPreferencesRepository.setShouldHideOnboarding(hide) } } + + fun setMenuTutorialDone(isDone: Boolean = true) { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setMenuTutorialDone(isDone) + } + } } data class MenuItemVisibility( diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index c8a48227..9445b6d5 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -102,6 +102,7 @@ private fun MenuScreen( } val menuItemVisibility by viewModel.menuItemVisibility.collectAsStateWithLifecycle() + val isMenuTutorialDone by viewModel.isMenuTutorialDone.collectAsStateWithLifecycle(true) Column(Modifier.fillMaxWidth()) { AppTopBar( @@ -115,32 +116,12 @@ private fun MenuScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - Row( - listItemModifier, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = t("~~Open tutorial"), - modifier = Modifier - .clickable( - onClick = viewModel.menuTutorialDirector::startTutorial, - ) - .listItemPadding(), - style = LocalFontStyles.current.header3, - color = primaryBlueColor, - ) - Spacer(Modifier.weight(1f)) - Text( - text = t("actions.done"), - modifier = Modifier - .clickable( - onClick = { - // TODO Move to bottom of screen - }, - ) - .listItemPadding(), - style = LocalFontStyles.current.header4, - color = primaryBlueColor, + if (!isMenuTutorialDone) { + MenuTutorial( + false, + viewModel.menuTutorialDirector::startTutorial, + viewModel::setMenuTutorialDone, + viewModel.isNotProduction, ) } @@ -182,6 +163,17 @@ private fun MenuScreen( enabled = true, ) + if (isMenuTutorialDone) { + val unsetMenuTutorialDone = + remember(viewModel) { { viewModel.setMenuTutorialDone(false) } } + MenuTutorial( + true, + viewModel.menuTutorialDirector::startTutorial, + unsetMenuTutorialDone, + viewModel.isNotProduction, + ) + } + Text( viewModel.versionText, listItemModifier, @@ -261,6 +253,45 @@ private fun MenuScreen( } } +@Composable +private fun MenuTutorial( + isTutorialDone: Boolean, + onStartTutorial: () -> Unit, + onTutorialDone: () -> Unit, + isNonProduction: Boolean = false, +) { + val t = LocalAppTranslator.current + + Row( + listItemModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = t("~~Open tutorial"), + modifier = Modifier + .clickable( + onClick = onStartTutorial, + ), + style = LocalFontStyles.current.header3, + color = primaryBlueColor, + ) + + if (!isTutorialDone || isNonProduction) { + Spacer(Modifier.weight(1f)) + Text( + text = t("actions.done"), + modifier = Modifier + .clickable( + onClick = onTutorialDone, + ) + .listItemPadding(), + style = LocalFontStyles.current.header4, + color = primaryBlueColor, + ) + } + } +} + @Composable private fun GettingStartedSection( showContent: Boolean, From 85384ab0b197c41699fafd7d4f818a8dc03f6c82 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 15:54:44 -0400 Subject: [PATCH 09/15] Prep for additional tutorial screens --- .../CrisisCleanupTutorialViewTracker.kt | 22 +++++++++++++++++++ .../crisiscleanup/MainActivityViewModel.kt | 13 ++++++++--- .../java/com/crisiscleanup/di/AppModule.kt | 5 +++++ .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 9 +++++--- .../com/crisiscleanup/ui/TutorialGraphics.kt | 6 ++++- .../core/appcomponent/ui/AppTopBar.kt | 9 +++++++- .../core/designsystem/component/TopAppBar.kt | 3 ++- .../core/model/data/TutorialViewId.kt | 7 ++++++ .../core/ui/TutorialViewTracker.kt | 8 +++++++ .../AuthenticationViewModelTest.kt | 1 + .../feature/menu/MenuViewModel.kt | 2 ++ .../feature/menu/ui/MenuScreen.kt | 16 +++++++++++++- 12 files changed, 91 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt create mode 100644 core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt create mode 100644 core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt diff --git a/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt b/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt new file mode 100644 index 00000000..041e00e7 --- /dev/null +++ b/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt @@ -0,0 +1,22 @@ +package com.crisiscleanup + +import androidx.compose.runtime.snapshots.SnapshotStateMap +import com.crisiscleanup.core.model.data.TutorialViewId +import com.crisiscleanup.core.model.data.TutorialViewId.AccountToggle +import com.crisiscleanup.core.model.data.TutorialViewId.AppNavBar +import com.crisiscleanup.core.model.data.TutorialViewId.IncidentSelectDropdown +import com.crisiscleanup.core.ui.LayoutSizePosition +import com.crisiscleanup.core.ui.TutorialViewTracker +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class CrisisCleanupTutorialViewTracker @Inject constructor( +) : TutorialViewTracker { + override val viewSizePositionLookup = + SnapshotStateMap().also { + it[AppNavBar] = LayoutSizePosition() + it[IncidentSelectDropdown] = LayoutSizePosition() + it[AccountToggle] = LayoutSizePosition() + } +} \ No newline at end of file diff --git a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt index 609163fa..3512e180 100644 --- a/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt +++ b/app/src/main/java/com/crisiscleanup/MainActivityViewModel.kt @@ -38,6 +38,7 @@ import com.crisiscleanup.core.model.data.EarlybirdEndOfLifeFallback import com.crisiscleanup.core.model.data.EmptyIncident import com.crisiscleanup.core.model.data.MinSupportedAppVersion import com.crisiscleanup.core.model.data.UserData +import com.crisiscleanup.core.ui.TutorialViewTracker import com.google.firebase.analytics.FirebaseAnalytics import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher @@ -71,6 +72,7 @@ class MainActivityViewModel @Inject constructor( private val accountDataRefresher: AccountDataRefresher, private val accountUpdateRepository: AccountUpdateRepository, @Tutorials(Menu) private val menuTutorialDirector: TutorialDirector, + val tutorialViewTracker: TutorialViewTracker, val translator: KeyResourceTranslator, private val syncPuller: SyncPuller, private val appVersionProvider: AppVersionProvider, @@ -314,17 +316,22 @@ class MainActivityViewModel @Inject constructor( } } + private fun setMenuTutorialDone() { + viewModelScope.launch(ioDispatcher) { + appPreferencesRepository.setMenuTutorialDone(true) + } + } + fun onMenuTutorialNext() { val hasNextStep = menuTutorialDirector.onNextStep() if (!hasNextStep) { - viewModelScope.launch(ioDispatcher) { - appPreferencesRepository.setMenuTutorialDone(true) - } + setMenuTutorialDone() } } fun closeMenuTutorial() { menuTutorialDirector.skipTutorial() + setMenuTutorialDone() } } diff --git a/app/src/main/java/com/crisiscleanup/di/AppModule.kt b/app/src/main/java/com/crisiscleanup/di/AppModule.kt index f557474a..42849ff0 100644 --- a/app/src/main/java/com/crisiscleanup/di/AppModule.kt +++ b/app/src/main/java/com/crisiscleanup/di/AppModule.kt @@ -8,6 +8,7 @@ import com.crisiscleanup.AndroidPermissionManager import com.crisiscleanup.AndroidPhoneNumberPicker import com.crisiscleanup.AppVisualAlertManager import com.crisiscleanup.CrisisCleanupAppEnv +import com.crisiscleanup.CrisisCleanupTutorialViewTracker import com.crisiscleanup.ZxingQrCodeGenerator import com.crisiscleanup.core.common.AppEnv import com.crisiscleanup.core.common.LocationProvider @@ -18,6 +19,7 @@ import com.crisiscleanup.core.common.VisualAlertManager import com.crisiscleanup.core.common.log.TagLogger import com.crisiscleanup.core.network.AuthInterceptorProvider import com.crisiscleanup.core.network.RetrofitInterceptorProvider +import com.crisiscleanup.core.ui.TutorialViewTracker import com.crisiscleanup.log.CrisisCleanupAppLogger import com.crisiscleanup.network.CrisisCleanupAuthInterceptorProvider import com.crisiscleanup.network.CrisisCleanupInterceptorProvider @@ -68,6 +70,9 @@ interface AppModule { @Singleton @Binds fun bindsPhoneNumberPicker(picker: AndroidPhoneNumberPicker): PhoneNumberPicker + + @Binds + fun bindsTutorialViewTracker(tracker: CrisisCleanupTutorialViewTracker): TutorialViewTracker } @Module diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 0300d095..77e34af2 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -36,6 +36,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color @@ -58,6 +59,7 @@ import com.crisiscleanup.core.designsystem.LocalLayoutProvider import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCenter import com.crisiscleanup.core.designsystem.component.CrisisCleanupBackground import com.crisiscleanup.core.designsystem.theme.LocalDimensions +import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.ui.AppLayoutArea import com.crisiscleanup.core.ui.LayoutSizePosition import com.crisiscleanup.core.ui.LocalAppLayout @@ -220,6 +222,7 @@ private fun BoxScope.LoadedContent( appState, isOnboarding, menuTutorialStep, + viewModel.tutorialViewTracker.viewSizePositionLookup, viewModel::onMenuTutorialNext, ) { openAuthentication = true } @@ -324,6 +327,7 @@ private fun NavigableContent( appState: CrisisCleanupAppState, isOnboarding: Boolean, menuTutorialStep: TutorialStep, + tutorialViewLookup: SnapshotStateMap, advanceMenuTutorial: () -> Unit, openAuthentication: () -> Unit, ) { @@ -331,9 +335,8 @@ private fun NavigableContent( val layoutBottomNav = LocalLayoutProvider.current.isBottomNav val isFullscreen = appState.isFullscreenRoute - var navBarSizePosition by remember { mutableStateOf(LayoutSizePosition()) } val navBarSizePositionModifier = Modifier.onGloballyPositioned { coordinates -> - navBarSizePosition = coordinates.sizePosition + tutorialViewLookup[TutorialViewId.AppNavBar] = coordinates.sizePosition } Scaffold( @@ -427,7 +430,7 @@ private fun NavigableContent( TutorialOverlay( tutorialStep = menuTutorialStep, onNextStep = advanceMenuTutorial, - navBarSizePosition = navBarSizePosition, + tutorialViewLookup = tutorialViewLookup, ) } } diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index 4abe8eea..376c573c 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.Canvas import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable +import androidx.compose.runtime.snapshots.SnapshotStateMap import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.Offset @@ -24,19 +25,22 @@ import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.sp import com.crisiscleanup.core.common.TutorialStep import com.crisiscleanup.core.designsystem.LocalAppTranslator +import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.ui.LayoutSizePosition @Composable internal fun TutorialOverlay( tutorialStep: TutorialStep, onNextStep: () -> Unit, - navBarSizePosition: LayoutSizePosition, + tutorialViewLookup: SnapshotStateMap, ) { val t = LocalAppTranslator.current val textMeasurer = rememberTextMeasurer() val stepForwardText = t("~~(Press anywhere on screen to continue tutorial)") + val navBarSizePosition = tutorialViewLookup[TutorialViewId.AppNavBar] ?: LayoutSizePosition() + // TODO Test recomposing/caching // TODO Animate/morph between steps Canvas( diff --git a/core/app-component/src/main/kotlin/com/crisiscleanup/core/appcomponent/ui/AppTopBar.kt b/core/app-component/src/main/kotlin/com/crisiscleanup/core/appcomponent/ui/AppTopBar.kt index 95ca6e86..46d0d6d5 100644 --- a/core/app-component/src/main/kotlin/com/crisiscleanup/core/appcomponent/ui/AppTopBar.kt +++ b/core/app-component/src/main/kotlin/com/crisiscleanup/core/appcomponent/ui/AppTopBar.kt @@ -18,6 +18,8 @@ import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons @Composable fun AppTopBar( modifier: Modifier = Modifier, + incidentDropdownModifier: Modifier = Modifier, + accountToggleModifier: Modifier = Modifier, dataProvider: AppTopBarDataProvider, openAuthentication: () -> Unit = {}, onOpenIncidents: (() -> Unit)? = null, @@ -32,6 +34,8 @@ fun AppTopBar( AppTopBar( modifier = modifier, + incidentDropdownModifier = incidentDropdownModifier, + accountToggleModifier = accountToggleModifier, title = screenTitle, isAppHeaderLoading = isHeaderLoading, profilePictureUri = profilePictureUri, @@ -48,6 +52,8 @@ fun AppTopBar( @Composable internal fun AppTopBar( modifier: Modifier = Modifier, + incidentDropdownModifier: Modifier = Modifier, + accountToggleModifier: Modifier = Modifier, title: String = "", isAppHeaderLoading: Boolean = false, profilePictureUri: String = "", @@ -60,6 +66,7 @@ internal fun AppTopBar( val actionText = t("actions.account") TopAppBarDefault( modifier = modifier, + accountToggleModifier = accountToggleModifier, title = title, profilePictureUri = profilePictureUri, actionIcon = CrisisCleanupIcons.Account, @@ -73,7 +80,7 @@ internal fun AppTopBar( TruncatedAppBarText(title = title) } else { IncidentDropdownSelect( - modifier = Modifier.testTag("appIncidentSelector"), + modifier = incidentDropdownModifier.testTag("appIncidentSelector"), onOpenIncidents, disasterIconResId, title = title, diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt index b8247c2a..10746fd1 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/TopAppBar.kt @@ -313,6 +313,7 @@ private fun AvatarAttentionBadge( @Composable fun TopAppBarDefault( modifier: Modifier = Modifier, + accountToggleModifier: Modifier = Modifier, @StringRes titleResId: Int = 0, title: String = "", navIcon: ImageVector? = null, @@ -345,7 +346,7 @@ fun TopAppBarDefault( AvatarAttentionBadge(isActionAttention) { IconButton( onClick = onActionClick, - modifier = Modifier.testTag("topBarAvatarIconBtn"), + modifier = accountToggleModifier.testTag("topBarAvatarIconBtn"), ) { AvatarIcon( profilePictureUri, diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt new file mode 100644 index 00000000..b256a59c --- /dev/null +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt @@ -0,0 +1,7 @@ +package com.crisiscleanup.core.model.data + +enum class TutorialViewId { + AppNavBar, + IncidentSelectDropdown, + AccountToggle, +} diff --git a/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt b/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt new file mode 100644 index 00000000..5713d5ac --- /dev/null +++ b/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt @@ -0,0 +1,8 @@ +package com.crisiscleanup.core.ui + +import androidx.compose.runtime.snapshots.SnapshotStateMap +import com.crisiscleanup.core.model.data.TutorialViewId + +interface TutorialViewTracker { + val viewSizePositionLookup: SnapshotStateMap +} \ No newline at end of file diff --git a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt index 40d171cf..ec8ad794 100644 --- a/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt +++ b/feature/authentication/src/test/java/com/crisiscleanup/feature/authentication/AuthenticationViewModelTest.kt @@ -133,6 +133,7 @@ class AuthenticationViewModelTest { tableViewSortBy = WorksiteSortBy.None, allowAllAnalytics = false, hideGettingStartedVideo = false, + isMenuTutorialDone = false, ), ) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt index e3c8805b..721b8c75 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuViewModel.kt @@ -23,6 +23,7 @@ import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.LocalAppPreferencesRepository import com.crisiscleanup.core.data.repository.SyncLogRepository import com.crisiscleanup.core.data.repository.WorksitesRepository +import com.crisiscleanup.core.ui.TutorialViewTracker import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -46,6 +47,7 @@ class MenuViewModel @Inject constructor( private val appEnv: AppEnv, private val syncPuller: SyncPuller, @Tutorials(Menu) val menuTutorialDirector: TutorialDirector, + val tutorialViewTracker: TutorialViewTracker, private val databaseVersionProvider: DatabaseVersionProvider, translator: KeyResourceTranslator, @ApplicationScope private val externalScope: CoroutineScope, diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index 9445b6d5..2ca3bf41 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -52,7 +53,9 @@ import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy import com.crisiscleanup.core.designsystem.theme.neutralFontColor import com.crisiscleanup.core.designsystem.theme.primaryBlueColor import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.TutorialViewId import com.crisiscleanup.core.selectincident.SelectIncidentDialog +import com.crisiscleanup.core.ui.sizePosition import com.crisiscleanup.feature.menu.MenuViewModel import com.crisiscleanup.feature.menu.R @@ -77,13 +80,13 @@ internal fun MenuRoute( @Composable private fun MenuScreen( - viewModel: MenuViewModel = hiltViewModel(), openAuthentication: () -> Unit = {}, openInviteTeammate: () -> Unit = {}, openRequestRedeploy: () -> Unit = {}, openUserFeedback: () -> Unit = {}, openLists: () -> Unit = {}, openSyncLogs: () -> Unit = {}, + viewModel: MenuViewModel = hiltViewModel(), ) { val t = LocalAppTranslator.current @@ -103,9 +106,20 @@ private fun MenuScreen( val menuItemVisibility by viewModel.menuItemVisibility.collectAsStateWithLifecycle() val isMenuTutorialDone by viewModel.isMenuTutorialDone.collectAsStateWithLifecycle(true) + val tutorialViewLookup = viewModel.tutorialViewTracker.viewSizePositionLookup + + val incidentDropdownModifier = Modifier.onGloballyPositioned { coordinates -> + tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] = coordinates.sizePosition + } + + val accountToggleModifier = Modifier.onGloballyPositioned { coordinates -> + tutorialViewLookup[TutorialViewId.AccountToggle] = coordinates.sizePosition + } Column(Modifier.fillMaxWidth()) { AppTopBar( + incidentDropdownModifier = incidentDropdownModifier, + accountToggleModifier = accountToggleModifier, dataProvider = viewModel.appTopBarDataProvider, openAuthentication = openAuthentication, onOpenIncidents = openIncidentsSelect, From 8d8ef9d77809816a49a489af2cb6ba266ed89ff2 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 16:29:56 -0400 Subject: [PATCH 10/15] Add tutorial steps for account info and incident select --- .../com/crisiscleanup/ui/TutorialGraphics.kt | 201 ++++++++++++++++-- .../feature/menu/MenuTutorialDirector.kt | 6 +- 2 files changed, 187 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index 376c573c..9be8e89d 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -95,11 +95,61 @@ internal fun TutorialOverlay( ) } + TutorialStep.AccountInfo -> { + drawStepForwardText( + textMeasurer, + stepForwardText, + spotlightAboveStepForwardOffset(isHorizontalBar), + ) + + val viewSizePosition = + tutorialViewLookup[TutorialViewId.AccountToggle] ?: LayoutSizePosition() + val viewSizeOffset = getAppBarSpotlightSizeOffset(viewSizePosition) + val stepInstruction = t("~~Open account information") + menuTutorialAccountToggle( + textMeasurer, + isHorizontalBar, + viewSizeOffset, + stepInstruction, + ) + } + + TutorialStep.IncidentSelect -> { + drawStepForwardText( + textMeasurer, + stepForwardText, + spotlightAboveStepForwardOffset(isHorizontalBar), + ) + + val viewSizePosition = tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] + ?: LayoutSizePosition() + val viewSizeOffset = getAppBarSpotlightSizeOffset(viewSizePosition, 1.2f) + val stepInstruction = t("~~Select and change Incidents") + menuTutorialSelectIncident( + textMeasurer, + viewSizeOffset, + stepInstruction, + ) + } + else -> {} } } } +private fun DrawScope.spotlightAboveStepForwardOffset(isHorizontalBar: Boolean) = + if (isHorizontalBar) { + Offset( + 32f, + size.height * 0.6f, + ) + } else { + Offset( + size.width * 0.3f, + size.height * 0.6f, + ) + } + private fun DrawScope.drawStepForwardText( textMeasurer: TextMeasurer, text: String, @@ -117,6 +167,28 @@ private fun DrawScope.drawStepForwardText( ) } +private data class SizeOffset( + val size: Size = Size.Zero, + val topLeft: Offset = Offset.Zero, +) + +private fun getNavBarSpotlightSizeOffset( + isHorizontalBar: Boolean, + navBarSizePosition: LayoutSizePosition, +): SizeOffset { + val navBarSize = navBarSizePosition.size + val spotlightWidth = navBarSize.width * 0.85f + val spotlightHeight = navBarSize.height * (if (isHorizontalBar) 0.5f else 0.95f) + val spotlightSize = Size(spotlightWidth, spotlightHeight) + val verticalOffset = if (isHorizontalBar) -64 else 0 + val navBarPosition = navBarSizePosition.position + val spotlightTopLeft = Offset( + navBarPosition.x + (navBarSize.width - spotlightSize.width) * 0.5f, + navBarPosition.y + (navBarSize.height - spotlightSize.height) * 0.5f + verticalOffset, + ) + return SizeOffset(spotlightSize, spotlightTopLeft) +} + private fun DrawScope.menuTutorialAppNav( textMeasurer: TextMeasurer, isHorizontalBar: Boolean, @@ -183,25 +255,120 @@ private fun DrawScope.menuTutorialAppNav( ) } -private data class SizeOffset( - val size: Size = Size.Zero, - val topLeft: Offset = Offset.Zero, -) - -private fun getNavBarSpotlightSizeOffset( - isHorizontalBar: Boolean, - navBarSizePosition: LayoutSizePosition, +private fun getAppBarSpotlightSizeOffset( + accountToggleSizePosition: LayoutSizePosition, + heightScale: Float = 1.1f, ): SizeOffset { - val navBarSize = navBarSizePosition.size - val spotlightWidth = navBarSize.width * 0.85f - val spotlightHeight = navBarSize.height * (if (isHorizontalBar) 0.5f else 0.95f) + val size = accountToggleSizePosition.size + val spotlightWidth = size.width * 1.1f + val spotlightHeight = size.height * heightScale val spotlightSize = Size(spotlightWidth, spotlightHeight) - val horizontalOffset = 0 - val verticalOffset = if (isHorizontalBar) -64 else 0 - val navBarPosition = navBarSizePosition.position + val position = accountToggleSizePosition.position val spotlightTopLeft = Offset( - navBarPosition.x + (navBarSize.width - spotlightSize.width) * 0.5f + horizontalOffset, - navBarPosition.y + (navBarSize.height - spotlightSize.height) * 0.5f + verticalOffset, + position.x - (spotlightWidth - size.width) * 0.5f, + position.y - (spotlightHeight - size.height) * 0.5f, ) return SizeOffset(spotlightSize, spotlightTopLeft) -} \ No newline at end of file +} + +private fun DrawScope.menuTutorialAccountToggle( + textMeasurer: TextMeasurer, + isHorizontalBar: Boolean, + sizeOffset: SizeOffset, + stepInstruction: String, +) { + val instructionOffset = Offset( + size.width * (if (isHorizontalBar) 0.1f else 0.3f), + size.height * 0.4f, + ) + val instructionStyle = TextStyle( + fontSize = 32.sp, + color = Color.White, + ) + + drawText( + textMeasurer = textMeasurer, + text = stepInstruction, + topLeft = instructionOffset, + style = instructionStyle, + overflow = TextOverflow.Visible, + ) + + val instructionConstraints = Constraints( + maxWidth = (size.width - instructionOffset.x).toInt(), + ) + val textLayout = textMeasurer.measure( + stepInstruction, + instructionStyle, + overflow = TextOverflow.Visible, + constraints = instructionConstraints, + ) + val textSize = textLayout.size + + val lineStartX = + if (isHorizontalBar) size.width * 0.5f else instructionOffset.x + textSize.width + 32 + val lineStartY = instructionOffset.y + (if (isHorizontalBar) -16 else 0) + val lineStart = Offset(lineStartX, lineStartY) + val lineEnd = Offset(sizeOffset.topLeft.x, sizeOffset.topLeft.y + sizeOffset.size.height) + drawLine( + Color.White, + lineStart, + lineEnd, + strokeWidth = 16f, + cap = StrokeCap.Round, + ) + + drawRoundRect( + color = Color.White, + topLeft = sizeOffset.topLeft, + size = sizeOffset.size, + cornerRadius = CornerRadius(sizeOffset.size.height * 0.5f), + blendMode = BlendMode.Clear, + ) +} + +private fun DrawScope.menuTutorialSelectIncident( + textMeasurer: TextMeasurer, + sizeOffset: SizeOffset, + stepInstruction: String, +) { + val instructionOffset = Offset( + size.width * 0.1f, + size.height * 0.4f, + ) + val instructionStyle = TextStyle( + fontSize = 32.sp, + color = Color.White, + ) + + drawText( + textMeasurer = textMeasurer, + text = stepInstruction, + topLeft = instructionOffset, + style = instructionStyle, + overflow = TextOverflow.Visible, + ) + + val lineStartX = size.width * 0.2f + val lineStartY = instructionOffset.y - 16 + val lineStart = Offset(lineStartX, lineStartY) + val lineEnd = Offset( + sizeOffset.topLeft.x + sizeOffset.size.width * 0.5f, + sizeOffset.topLeft.y + sizeOffset.size.height + 32f, + ) + drawLine( + Color.White, + lineStart, + lineEnd, + strokeWidth = 16f, + cap = StrokeCap.Round, + ) + + drawRoundRect( + color = Color.White, + topLeft = sizeOffset.topLeft, + size = sizeOffset.size, + cornerRadius = CornerRadius(sizeOffset.size.height * 0.5f), + blendMode = BlendMode.Clear, + ) +} diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt index 4caaac59..073a9e7f 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject class MenuTutorialDirector @Inject constructor() : TutorialDirector { - private val stepFLow = MutableStateFlow(TutorialStep.End) + private val stepFLow = MutableStateFlow(TutorialStep.IncidentSelect) override val tutorialStep = stepFLow override fun startTutorial() { @@ -21,9 +21,9 @@ class MenuTutorialDirector @Inject constructor() : TutorialDirector { val nextStep = when (tutorialStep.value) { TutorialStep.MenuStart, TutorialStep.AppNavBar, - -> TutorialStep.End + -> TutorialStep.AccountInfo - // TODO Other steps + TutorialStep.AccountInfo -> TutorialStep.IncidentSelect else -> TutorialStep.End } From 69c06079ba058b55b3631c6f9ba196257366b69b Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 16:57:10 -0400 Subject: [PATCH 11/15] Start menu tutorial on correct step --- .../java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt index 073a9e7f..5bdbcb9b 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject class MenuTutorialDirector @Inject constructor() : TutorialDirector { - private val stepFLow = MutableStateFlow(TutorialStep.IncidentSelect) + private val stepFLow = MutableStateFlow(TutorialStep.MenuStart) override val tutorialStep = stepFLow override fun startTutorial() { From dec8ccb110d974599714bd8af547b0a0c21f0729 Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 19:23:21 -0400 Subject: [PATCH 12/15] Show message when there are no Incidents to deploy to --- .../ui/RequestRedeployScreen.kt | 97 ++++++++++--------- 1 file changed, 52 insertions(+), 45 deletions(-) diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt index 9a9b304b..c9fe2d93 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt @@ -60,55 +60,62 @@ fun RequestRedeployRoute( } else { (viewState as? RequestRedeployViewState.Ready)?.let { readyState -> val incidents = readyState.incidents - val isTransient by viewModel.isTransient.collectAsStateWithLifecycle(true) - val isEditable = !isTransient - val errorMessage = viewModel.redeployErrorMessage - val requestedIncidentIds = viewModel.requestedIncidentIds - val isRequestingRedeploy by viewModel.isRequestingRedeploy.collectAsStateWithLifecycle() - var selectedIncident by remember { mutableStateOf(EmptyIncident) } - val onSelectIncident = remember(viewModel) { - { incident: Incident -> - selectedIncident = incident + if (incidents.isEmpty()) { + Text( + t("~~There are no Incidents left for deploying."), + listItemModifier, + ) + } else { + val isTransient by viewModel.isTransient.collectAsStateWithLifecycle(true) + val isEditable = !isTransient + val errorMessage = viewModel.redeployErrorMessage + val requestedIncidentIds = viewModel.requestedIncidentIds + val isRequestingRedeploy by viewModel.isRequestingRedeploy.collectAsStateWithLifecycle() + var selectedIncident by remember { mutableStateOf(EmptyIncident) } + val onSelectIncident = remember(viewModel) { + { incident: Incident -> + selectedIncident = incident + } } - } - val selectIncidentHint = t("actions.select_incident") + val selectIncidentHint = t("actions.select_incident") - RequestRedeployContent( - isEditable, - incidents, - requestedIncidentIds, - errorMessage, - selectedIncident.name.ifBlank { selectIncidentHint }, - selectIncidentHint, - onSelectIncident, - rememberKey = viewModel, - ) + RequestRedeployContent( + isEditable, + incidents, + requestedIncidentIds, + errorMessage, + selectedIncident.name.ifBlank { selectIncidentHint }, + selectIncidentHint, + onSelectIncident, + rememberKey = viewModel, + ) - Spacer(Modifier.weight(1f)) + Spacer(Modifier.weight(1f)) - Row( - listItemModifier, - horizontalArrangement = listItemSpacedBy, - ) { - BusyButton( - Modifier - .testTag("requestRedeployCancelAction") - .weight(1f), - text = t("actions.cancel"), - enabled = isEditable, - indicateBusy = false, - onClick = onBack, - colors = cancelButtonColors(), - ) - BusyButton( - Modifier - .testTag("requestRedeploySubmitAction") - .weight(1f), - text = t("actions.submit"), - enabled = isEditable && selectedIncident != EmptyIncident, - indicateBusy = isRequestingRedeploy, - onClick = { viewModel.requestRedeploy(selectedIncident) }, - ) + Row( + listItemModifier, + horizontalArrangement = listItemSpacedBy, + ) { + BusyButton( + Modifier + .testTag("requestRedeployCancelAction") + .weight(1f), + text = t("actions.cancel"), + enabled = isEditable, + indicateBusy = false, + onClick = onBack, + colors = cancelButtonColors(), + ) + BusyButton( + Modifier + .testTag("requestRedeploySubmitAction") + .weight(1f), + text = t("actions.submit"), + enabled = isEditable && selectedIncident != EmptyIncident, + indicateBusy = isRequestingRedeploy, + onClick = { viewModel.requestRedeploy(selectedIncident) }, + ) + } } } } From ac9dcbf06af4391e6b87052bee103ddf6bf810ed Mon Sep 17 00:00:00 2001 From: hue Date: Tue, 13 Aug 2024 20:22:32 -0400 Subject: [PATCH 13/15] Start on additional tutorial steps --- app/build.gradle.kts | 2 +- .../com/crisiscleanup/ui/TutorialGraphics.kt | 36 +++++++++++++++ .../core/common/TutorialDirector.kt | 1 + .../core/model/data/TutorialViewId.kt | 2 + .../feature/menu/MenuTutorialDirector.kt | 10 +++-- .../feature/menu/ui/MenuScreen.kt | 45 ++++++++++++++++--- 6 files changed, 87 insertions(+), 9 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f09e524d..e5924bcd 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ plugins { android { defaultConfig { - val buildVersion = 220 + val buildVersion = 222 applicationId = "com.crisiscleanup" versionCode = buildVersion versionName = "0.9.${buildVersion - 168}" diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index 9be8e89d..02ffd0ee 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -62,6 +62,28 @@ internal fun TutorialOverlay( val isHorizontalBar = navBarSize.width > size.width * 0.5f when (tutorialStep) { TutorialStep.MenuStart, + TutorialStep.InviteTeammates, + TutorialStep.ProvideAppFeedback, + -> { + // TODO This requires a bit of testing to guarantee the view is centered... + val viewSizePosition = + tutorialViewLookup[TutorialViewId.InviteTeammate] ?: LayoutSizePosition() + drawStepForwardText( + textMeasurer, + stepForwardText, + spotlightStepForwardOffset(isHorizontalBar, viewSizePosition), + ) + +// val viewSizeOffset = getDynamicSpotlightSizeOffset(viewSizePosition) +// val stepInstruction = t("~~Invite teammates to Crisis Cleanup") +// menuTutorialDynamicContent( +// textMeasurer, +// isHorizontalBar, +// viewSizeOffset, +// stepInstruction, +// ) + } + TutorialStep.AppNavBar, -> { val navBarSizeOffset = getNavBarSpotlightSizeOffset( @@ -137,6 +159,20 @@ internal fun TutorialOverlay( } } +private fun DrawScope.spotlightStepForwardOffset( + isHorizontalBar: Boolean, + viewSizePosition: LayoutSizePosition, +): Offset { + val center = viewSizePosition.position.y + viewSizePosition.size.height * 0.5f + val y = size.height * (if (center > size.height * 0.5f) { + 0.3f + } else { + 0.7f + }) + val x = if (isHorizontalBar) 32f else size.width * 0.3f + return Offset(x, y) +} + private fun DrawScope.spotlightAboveStepForwardOffset(isHorizontalBar: Boolean) = if (isHorizontalBar) { Offset( diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt index 7c7c2a8d..6523de45 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt @@ -19,6 +19,7 @@ interface TutorialDirector { enum class TutorialStep { MenuStart, + InviteTeammates, AppNavBar, AccountInfo, ProvideAppFeedback, diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt index b256a59c..ee73919e 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/TutorialViewId.kt @@ -1,7 +1,9 @@ package com.crisiscleanup.core.model.data enum class TutorialViewId { + InviteTeammate, AppNavBar, IncidentSelectDropdown, AccountToggle, + ProvideFeedback, } diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt index 5bdbcb9b..838ea831 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -6,7 +6,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import javax.inject.Inject class MenuTutorialDirector @Inject constructor() : TutorialDirector { - private val stepFLow = MutableStateFlow(TutorialStep.MenuStart) + private val stepFLow = MutableStateFlow(TutorialStep.End) override val tutorialStep = stepFLow override fun startTutorial() { @@ -20,11 +20,15 @@ class MenuTutorialDirector @Inject constructor() : TutorialDirector { override fun onNextStep(): Boolean { val nextStep = when (tutorialStep.value) { TutorialStep.MenuStart, - TutorialStep.AppNavBar, - -> TutorialStep.AccountInfo + TutorialStep.InviteTeammates, + -> TutorialStep.AppNavBar + + TutorialStep.AppNavBar -> TutorialStep.AccountInfo TutorialStep.AccountInfo -> TutorialStep.IncidentSelect + TutorialStep.IncidentSelect -> TutorialStep.ProvideAppFeedback + else -> TutorialStep.End } tutorialStep.value = nextStep diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index 2ca3bf41..e6d1e768 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -38,6 +39,7 @@ import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.crisiscleanup.core.appcomponent.ui.AppTopBar +import com.crisiscleanup.core.common.TutorialStep import com.crisiscleanup.core.designsystem.LocalAppTranslator import com.crisiscleanup.core.designsystem.component.CrisisCleanupButton import com.crisiscleanup.core.designsystem.component.CrisisCleanupOutlinedButton @@ -108,13 +110,19 @@ private fun MenuScreen( val isMenuTutorialDone by viewModel.isMenuTutorialDone.collectAsStateWithLifecycle(true) val tutorialViewLookup = viewModel.tutorialViewTracker.viewSizePositionLookup + val tutorialStep by viewModel.menuTutorialDirector.tutorialStep.collectAsStateWithLifecycle() val incidentDropdownModifier = Modifier.onGloballyPositioned { coordinates -> tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] = coordinates.sizePosition } - val accountToggleModifier = Modifier.onGloballyPositioned { coordinates -> tutorialViewLookup[TutorialViewId.AccountToggle] = coordinates.sizePosition } + val inviteTeammateModifier = Modifier.onGloballyPositioned { coordinates -> + tutorialViewLookup[TutorialViewId.InviteTeammate] = coordinates.sizePosition + } + val provideFeedbackModifier = Modifier.onGloballyPositioned { coordinates -> + tutorialViewLookup[TutorialViewId.ProvideFeedback] = coordinates.sizePosition + } Column(Modifier.fillMaxWidth()) { AppTopBar( @@ -125,10 +133,32 @@ private fun MenuScreen( onOpenIncidents = openIncidentsSelect, ) + val scrollState = rememberScrollState() + LaunchedEffect(tutorialStep) { + // TODO How to guarantee centering the view? + when (tutorialStep) { + TutorialStep.MenuStart, + TutorialStep.InviteTeammates, + -> { + tutorialViewLookup[TutorialViewId.InviteTeammate]?.let { sizePosition -> + scrollState.scrollTo(sizePosition.position.y.toInt() - 300) + } + } + + TutorialStep.ProvideAppFeedback -> { + tutorialViewLookup[TutorialViewId.ProvideFeedback]?.let { sizePosition -> + scrollState.scrollTo(sizePosition.position.y.toInt() - 300) + } + } + + else -> {} + } + } + Column( Modifier .fillMaxSize() - .verticalScroll(rememberScrollState()), + .verticalScroll(scrollState), ) { if (!isMenuTutorialDone) { MenuTutorial( @@ -158,7 +188,9 @@ private fun MenuScreen( ) CrisisCleanupButton( - modifier = listItemModifier, + modifier = inviteTeammateModifier + .fillMaxWidth() + .listItemPadding(), text = t("usersVue.invite_new_user"), onClick = openInviteTeammate, ) @@ -171,7 +203,10 @@ private fun MenuScreen( ) CrisisCleanupOutlinedButton( - modifier = listItemModifier.actionHeight(), + modifier = provideFeedbackModifier + .fillMaxWidth() + .listItemPadding() + .actionHeight(), text = t("info.give_app_feedback"), onClick = openUserFeedback, enabled = true, @@ -293,7 +328,7 @@ private fun MenuTutorial( if (!isTutorialDone || isNonProduction) { Spacer(Modifier.weight(1f)) Text( - text = t("actions.done"), + text = if (!isTutorialDone) t("actions.done") else "Reset", modifier = Modifier .clickable( onClick = onTutorialDone, From ac0a42f3e90f6a4b76caac48e075154d1170b405 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 14 Aug 2024 18:53:53 -0400 Subject: [PATCH 14/15] Complete menu tutorial --- .../com/crisiscleanup/ui/TutorialGraphics.kt | 162 ++++++++-- .../feature/menu/ui/MenuScreen.kt | 277 +++++++++++------- 2 files changed, 301 insertions(+), 138 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index 02ffd0ee..e4f1116b 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.drawText import androidx.compose.ui.text.rememberTextMeasurer import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.sp import com.crisiscleanup.core.common.TutorialStep import com.crisiscleanup.core.designsystem.LocalAppTranslator @@ -65,23 +66,30 @@ internal fun TutorialOverlay( TutorialStep.InviteTeammates, TutorialStep.ProvideAppFeedback, -> { - // TODO This requires a bit of testing to guarantee the view is centered... - val viewSizePosition = - tutorialViewLookup[TutorialViewId.InviteTeammate] ?: LayoutSizePosition() + val viewId = if (tutorialStep == TutorialStep.ProvideAppFeedback) { + TutorialViewId.ProvideFeedback + } else { + TutorialViewId.InviteTeammate + } + val viewSizePosition = tutorialViewLookup[viewId] ?: LayoutSizePosition() drawStepForwardText( textMeasurer, stepForwardText, spotlightStepForwardOffset(isHorizontalBar, viewSizePosition), ) -// val viewSizeOffset = getDynamicSpotlightSizeOffset(viewSizePosition) -// val stepInstruction = t("~~Invite teammates to Crisis Cleanup") -// menuTutorialDynamicContent( -// textMeasurer, -// isHorizontalBar, -// viewSizeOffset, -// stepInstruction, -// ) + val viewSizeOffset = getDynamicSpotlightSizeOffset(viewSizePosition) + val instructionKey = if (viewId == TutorialViewId.ProvideFeedback) { + "~~Let us know of any issues with this app" + } else { + "~~Invite teammates to Crisis Cleanup" + } + val stepInstruction = t(instructionKey) + menuTutorialDynamicContent( + textMeasurer, + viewSizeOffset, + stepInstruction, + ) } TutorialStep.AppNavBar, @@ -98,7 +106,7 @@ internal fun TutorialOverlay( ) } else { Offset( - size.width * 0.3f, + navBarSizeOffset.topLeft.x + size.width * 0.2f, size.height * 0.3f, ) } @@ -118,14 +126,15 @@ internal fun TutorialOverlay( } TutorialStep.AccountInfo -> { + val viewSizePosition = + tutorialViewLookup[TutorialViewId.AccountToggle] ?: LayoutSizePosition() + drawStepForwardText( textMeasurer, stepForwardText, spotlightAboveStepForwardOffset(isHorizontalBar), ) - val viewSizePosition = - tutorialViewLookup[TutorialViewId.AccountToggle] ?: LayoutSizePosition() val viewSizeOffset = getAppBarSpotlightSizeOffset(viewSizePosition) val stepInstruction = t("~~Open account information") menuTutorialAccountToggle( @@ -137,14 +146,15 @@ internal fun TutorialOverlay( } TutorialStep.IncidentSelect -> { + val viewSizePosition = tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] + ?: LayoutSizePosition() + drawStepForwardText( textMeasurer, stepForwardText, - spotlightAboveStepForwardOffset(isHorizontalBar), + spotlightAboveStepForwardOffset(isHorizontalBar, viewSizePosition), ) - val viewSizePosition = tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] - ?: LayoutSizePosition() val viewSizeOffset = getAppBarSpotlightSizeOffset(viewSizePosition, 1.2f) val stepInstruction = t("~~Select and change Incidents") menuTutorialSelectIncident( @@ -165,26 +175,114 @@ private fun DrawScope.spotlightStepForwardOffset( ): Offset { val center = viewSizePosition.position.y + viewSizePosition.size.height * 0.5f val y = size.height * (if (center > size.height * 0.5f) { - 0.3f + 0.2f } else { - 0.7f + 0.8f }) - val x = if (isHorizontalBar) 32f else size.width * 0.3f + val x = viewSizePosition.position.x + if (isHorizontalBar) { + 32f + } else { + viewSizePosition.size.width * 0.2f + } return Offset(x, y) } -private fun DrawScope.spotlightAboveStepForwardOffset(isHorizontalBar: Boolean) = - if (isHorizontalBar) { - Offset( - 32f, - size.height * 0.6f, - ) - } else { - Offset( - size.width * 0.3f, - size.height * 0.6f, - ) - } +private fun getDynamicSpotlightSizeOffset( + sizePosition: LayoutSizePosition, +): SizeOffset { + val size = sizePosition.size + val spotlightWidth = size.width * 1f + val spotlightHeight = size.height * 1.2f + val spotlightSize = Size(spotlightWidth, spotlightHeight) + val position = sizePosition.position + val spotlightTopLeft = Offset( + position.x + (size.width - spotlightWidth) * 0.5f, + position.y + (size.height - spotlightHeight) * 0.5f, + ) + return SizeOffset(spotlightSize, spotlightTopLeft) +} + +private fun DrawScope.menuTutorialDynamicContent( + textMeasurer: TextMeasurer, + sizeOffset: SizeOffset, + stepInstruction: String, +) { + val center = sizeOffset.topLeft.y + sizeOffset.size.height * 0.5f + val isSpotlightCenterAbove = center < size.height * 0.5f + + val instructionOffset = Offset( + sizeOffset.topLeft.x + sizeOffset.size.width * 0.1f, + size.height * (if (isSpotlightCenterAbove) 0.6f else 0.35f), + ) + val instructionStyle = TextStyle( + fontSize = 32.sp, + color = Color.White, + ) + + drawText( + textMeasurer = textMeasurer, + text = stepInstruction, + topLeft = instructionOffset, + style = instructionStyle, + overflow = TextOverflow.Visible, + ) + + val lineX = size.width * 0.5f + val lineStartY = + instructionOffset.y + (if (isSpotlightCenterAbove) { + -16f + } else { + val instructionConstraints = Constraints( + maxWidth = (size.width - instructionOffset.x).toInt(), + ) + val textLayout = textMeasurer.measure( + stepInstruction, + instructionStyle, + overflow = TextOverflow.Visible, + constraints = instructionConstraints, + ) + val textSize = textLayout.size + + textSize.height.toFloat() + 16f + }) + val lineStart = Offset(lineX, lineStartY) + val lineEndY = + sizeOffset.topLeft.y + (if (isSpotlightCenterAbove) sizeOffset.size.height + 32f else -32f) + val lineEnd = Offset(lineX, lineEndY) + drawLine( + Color.White, + lineStart, + lineEnd, + strokeWidth = 16f, + cap = StrokeCap.Round, + ) + + drawRoundRect( + color = Color.White, + topLeft = sizeOffset.topLeft, + size = sizeOffset.size, + cornerRadius = CornerRadius(sizeOffset.size.height * 0.5f), + blendMode = BlendMode.Clear, + ) +} + +private fun DrawScope.spotlightAboveStepForwardOffset( + isHorizontalBar: Boolean, + viewSizePosition: LayoutSizePosition? = null, +): Offset { + val referencePosition = viewSizePosition ?: LayoutSizePosition( + IntSize(size.width.toInt(), 0), + Offset(if (isHorizontalBar) 0f else 32f, 0f), + ) + val x = referencePosition.position.x + + if (isHorizontalBar) { + 32f + } else { + referencePosition.size.width * 0.2f + } + val y = size.height * 0.6f + return Offset(x, y) +} private fun DrawScope.drawStepForwardText( textMeasurer: TextMeasurer, diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt index e6d1e768..5a940845 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/ui/MenuScreen.kt @@ -12,9 +12,9 @@ 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.rememberScrollState +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.Icon @@ -23,6 +23,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -33,6 +34,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp @@ -110,6 +112,9 @@ private fun MenuScreen( val isMenuTutorialDone by viewModel.isMenuTutorialDone.collectAsStateWithLifecycle(true) val tutorialViewLookup = viewModel.tutorialViewTracker.viewSizePositionLookup + val isGettingStartedVisible = + menuItemVisibility.showGettingStartedVideo || viewModel.isNotProduction + val tutorialStep by viewModel.menuTutorialDirector.tutorialStep.collectAsStateWithLifecycle() val incidentDropdownModifier = Modifier.onGloballyPositioned { coordinates -> tutorialViewLookup[TutorialViewId.IncidentSelectDropdown] = coordinates.sizePosition @@ -124,7 +129,7 @@ private fun MenuScreen( tutorialViewLookup[TutorialViewId.ProvideFeedback] = coordinates.sizePosition } - Column(Modifier.fillMaxWidth()) { + Column { AppTopBar( incidentDropdownModifier = incidentDropdownModifier, accountToggleModifier = accountToggleModifier, @@ -133,21 +138,47 @@ private fun MenuScreen( onOpenIncidents = openIncidentsSelect, ) - val scrollState = rememberScrollState() + val lazyListState = rememberLazyListState() + val firstVisibleItemIndex by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo[0].index + } + } + val lastVisibleItemIndex by remember { + derivedStateOf { + lazyListState.layoutInfo.visibleItemsInfo.last().index + } + } + val focusItemScrollOffset = (-72 * LocalDensity.current.density).toInt() LaunchedEffect(tutorialStep) { - // TODO How to guarantee centering the view? + fun getListItemIndex(itemIndex: Int): Int { + var listItemIndex = itemIndex + if (!isMenuTutorialDone) { + listItemIndex += 1 + } + if (isGettingStartedVisible) { + listItemIndex += 1 + } + return listItemIndex + } when (tutorialStep) { TutorialStep.MenuStart, TutorialStep.InviteTeammates, -> { - tutorialViewLookup[TutorialViewId.InviteTeammate]?.let { sizePosition -> - scrollState.scrollTo(sizePosition.position.y.toInt() - 300) + val listItemIndex = getListItemIndex(2) + if (firstVisibleItemIndex > listItemIndex - 2 || + lastVisibleItemIndex < listItemIndex + 2 + ) { + lazyListState.scrollToItem(listItemIndex, focusItemScrollOffset) } } TutorialStep.ProvideAppFeedback -> { - tutorialViewLookup[TutorialViewId.ProvideFeedback]?.let { sizePosition -> - scrollState.scrollTo(sizePosition.position.y.toInt() - 300) + val listItemIndex = getListItemIndex(4) + if (firstVisibleItemIndex > listItemIndex - 2 || + lastVisibleItemIndex < listItemIndex + 2 + ) { + lazyListState.scrollToItem(listItemIndex, focusItemScrollOffset) } } @@ -155,130 +186,164 @@ private fun MenuScreen( } } - Column( - Modifier - .fillMaxSize() - .verticalScroll(scrollState), + LazyColumn( + Modifier.fillMaxSize(), + state = lazyListState, ) { if (!isMenuTutorialDone) { - MenuTutorial( - false, - viewModel.menuTutorialDirector::startTutorial, - viewModel::setMenuTutorialDone, - viewModel.isNotProduction, - ) + item { + MenuTutorial( + false, + viewModel.menuTutorialDirector::startTutorial, + viewModel::setMenuTutorialDone, + viewModel.isNotProduction, + ) + } } - val hideGettingStartedVideo = remember(viewModel) { - { viewModel.showGettingStartedVideo(false) } + if (isGettingStartedVisible) { + item { + val hideGettingStartedVideo = remember(viewModel) { + { viewModel.showGettingStartedVideo(false) } + } + GettingStartedSection( + menuItemVisibility.showGettingStartedVideo, + hideGettingStartedVideo = hideGettingStartedVideo, + viewModel.gettingStartedVideoUrl, + viewModel.isNotProduction, + toggleGettingStartedSection = viewModel::showGettingStartedVideo, + ) + } } - GettingStartedSection( - menuItemVisibility.showGettingStartedVideo, - hideGettingStartedVideo = hideGettingStartedVideo, - viewModel.gettingStartedVideoUrl, - viewModel.isNotProduction, - toggleGettingStartedSection = viewModel::showGettingStartedVideo, - ) - CrisisCleanupOutlinedButton( - modifier = listItemModifier.actionHeight(), - text = t("list.lists"), - onClick = openLists, - enabled = true, - ) - - CrisisCleanupButton( - modifier = inviteTeammateModifier - .fillMaxWidth() - .listItemPadding(), - text = t("usersVue.invite_new_user"), - onClick = openInviteTeammate, - ) - - CrisisCleanupOutlinedButton( - modifier = listItemModifier.actionHeight(), - text = t("requestRedeploy.request_redeploy"), - onClick = openRequestRedeploy, - enabled = true, - ) - - CrisisCleanupOutlinedButton( - modifier = provideFeedbackModifier - .fillMaxWidth() - .listItemPadding() - .actionHeight(), - text = t("info.give_app_feedback"), - onClick = openUserFeedback, - enabled = true, - ) - - if (isMenuTutorialDone) { - val unsetMenuTutorialDone = - remember(viewModel) { { viewModel.setMenuTutorialDone(false) } } - MenuTutorial( - true, - viewModel.menuTutorialDirector::startTutorial, - unsetMenuTutorialDone, - viewModel.isNotProduction, + item( + key = "lists-item", + contentType = "outline-button", + ) { + CrisisCleanupOutlinedButton( + modifier = listItemModifier.actionHeight(), + text = t("list.lists"), + onClick = openLists, + enabled = true, ) } - Text( - viewModel.versionText, - listItemModifier, - color = neutralFontColor, - ) + item( + key = "invite-teammate-item", + contentType = "primary-button", + ) { + CrisisCleanupButton( + modifier = inviteTeammateModifier + .fillMaxWidth() + .listItemPadding(), + text = t("usersVue.invite_new_user"), + onClick = openInviteTeammate, + ) + } - Row( - Modifier - .clickable( - onClick = { shareAnalytics(!isSharingAnalytics) }, - ) - .then(listItemModifier), - verticalAlignment = Alignment.CenterVertically, + item( + key = "redeploy-item", + contentType = "outline-button", ) { - Text( - t("actions.share_analytics"), - Modifier.weight(1f), + CrisisCleanupOutlinedButton( + modifier = listItemModifier.actionHeight(), + text = t("requestRedeploy.request_redeploy"), + onClick = openRequestRedeploy, + enabled = true, ) - Switch( - checked = isSharingAnalytics, - onCheckedChange = shareAnalytics, + } + + item( + key = "feedback-item", + contentType = "outline-button", + ) { + CrisisCleanupOutlinedButton( + modifier = provideFeedbackModifier + .fillMaxWidth() + .listItemPadding() + .actionHeight(), + text = t("info.give_app_feedback"), + onClick = openUserFeedback, + enabled = true, ) } - val uriHandler = LocalUriHandler.current + if (isMenuTutorialDone) { + item { + val unsetMenuTutorialDone = + remember(viewModel) { { viewModel.setMenuTutorialDone(false) } } + MenuTutorial( + true, + viewModel.menuTutorialDirector::startTutorial, + unsetMenuTutorialDone, + viewModel.isNotProduction, + ) + } + } - Spacer(Modifier.weight(1f)) + item { + Text( + viewModel.versionText, + listItemModifier, + color = neutralFontColor, + ) + } - // TODO Open in WebView? - Row( - listItemModifier, - horizontalArrangement = Arrangement.Center, - ) { - CrisisCleanupTextButton( - Modifier.actionHeight(), - text = t("publicNav.terms"), + item { + Row( + Modifier + .clickable( + onClick = { shareAnalytics(!isSharingAnalytics) }, + ) + .then(listItemModifier), + verticalAlignment = Alignment.CenterVertically, ) { - uriHandler.openUri(viewModel.termsOfServiceUrl) + Text( + t("actions.share_analytics"), + Modifier.weight(1f), + ) + Switch( + checked = isSharingAnalytics, + onCheckedChange = shareAnalytics, + ) } - CrisisCleanupTextButton( - Modifier.actionHeight(), - text = t("nav.privacy"), + } + + // TODO Open in WebView? + item { + val uriHandler = LocalUriHandler.current + Row( + listItemModifier, + horizontalArrangement = Arrangement.Center, ) { - uriHandler.openUri(viewModel.privacyPolicyUrl) + CrisisCleanupTextButton( + Modifier.actionHeight(), + text = t("publicNav.terms"), + ) { + uriHandler.openUri(viewModel.termsOfServiceUrl) + } + CrisisCleanupTextButton( + Modifier.actionHeight(), + text = t("nav.privacy"), + ) { + uriHandler.openUri(viewModel.privacyPolicyUrl) + } } } if (viewModel.isDebuggable) { - MenuScreenNonProductionView() + item { + MenuScreenNonProductionView() + } } if (viewModel.isNotProduction) { - CrisisCleanupTextButton( - onClick = openSyncLogs, - text = "See sync logs", - ) + item { + CrisisCleanupTextButton( + onClick = openSyncLogs, + text = "See sync logs", + ) + } } } } From e802df41dd5e764d1a5d436c83abb47ec2d1b046 Mon Sep 17 00:00:00 2001 From: hue Date: Wed, 14 Aug 2024 20:18:16 -0400 Subject: [PATCH 15/15] Update request redeploy querying all incidents --- .../CrisisCleanupTutorialViewTracker.kt | 5 +- .../com/crisiscleanup/ui/CrisisCleanupApp.kt | 1 - .../com/crisiscleanup/ui/TutorialGraphics.kt | 54 +++---- .../core/common/TutorialDirector.kt | 4 +- .../data/repository/IncidentsRepository.kt | 1 + .../OfflineFirstIncidentsRepository.kt | 18 +++ .../component/ListOptionsDropdown.kt | 1 + .../designsystem/icon/CrisisCleanupIcons.kt | 2 + .../core/designsystem/theme/Color.kt | 1 + .../core/model/data/CrisisCleanupList.kt | 2 +- .../crisiscleanup/core/model/data/Incident.kt | 10 ++ .../network/CrisisCleanupNetworkDataSource.kt | 7 + .../core/network/model/NetworkIncident.kt | 17 +++ .../core/network/retrofit/DataApiClient.kt | 21 +++ .../core/ui/LayoutSizePosition.kt | 2 +- .../core/ui/TutorialViewTracker.kt | 2 +- .../caseeditor/QueryIncidentsManager.kt | 8 +- .../feature/menu/MenuTutorialDirector.kt | 2 +- .../feature/menu/di/MenuModule.kt | 4 +- .../RequestRedeployViewModel.kt | 40 +++--- .../ui/IncidentsDropdown.kt | 57 ++------ .../ui/InviteTeammateScreen.kt | 74 ++++++---- .../ui/RequestRedeployScreen.kt | 132 +++++++++++++----- 23 files changed, 299 insertions(+), 166 deletions(-) diff --git a/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt b/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt index 041e00e7..980a650b 100644 --- a/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt +++ b/app/src/main/java/com/crisiscleanup/CrisisCleanupTutorialViewTracker.kt @@ -11,12 +11,11 @@ import javax.inject.Inject import javax.inject.Singleton @Singleton -class CrisisCleanupTutorialViewTracker @Inject constructor( -) : TutorialViewTracker { +class CrisisCleanupTutorialViewTracker @Inject constructor() : TutorialViewTracker { override val viewSizePositionLookup = SnapshotStateMap().also { it[AppNavBar] = LayoutSizePosition() it[IncidentSelectDropdown] = LayoutSizePosition() it[AccountToggle] = LayoutSizePosition() } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt index 77e34af2..6a4d7277 100644 --- a/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt +++ b/app/src/main/java/com/crisiscleanup/ui/CrisisCleanupApp.kt @@ -457,4 +457,3 @@ private fun ExpiredAccountAlert( } } } - diff --git a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt index e4f1116b..9a3561b6 100644 --- a/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt +++ b/app/src/main/java/com/crisiscleanup/ui/TutorialGraphics.kt @@ -174,11 +174,13 @@ private fun DrawScope.spotlightStepForwardOffset( viewSizePosition: LayoutSizePosition, ): Offset { val center = viewSizePosition.position.y + viewSizePosition.size.height * 0.5f - val y = size.height * (if (center > size.height * 0.5f) { - 0.2f - } else { - 0.8f - }) + val y = size.height * ( + if (center > size.height * 0.5f) { + 0.2f + } else { + 0.8f + } + ) val x = viewSizePosition.position.x + if (isHorizontalBar) { 32f } else { @@ -229,22 +231,24 @@ private fun DrawScope.menuTutorialDynamicContent( val lineX = size.width * 0.5f val lineStartY = - instructionOffset.y + (if (isSpotlightCenterAbove) { - -16f - } else { - val instructionConstraints = Constraints( - maxWidth = (size.width - instructionOffset.x).toInt(), - ) - val textLayout = textMeasurer.measure( - stepInstruction, - instructionStyle, - overflow = TextOverflow.Visible, - constraints = instructionConstraints, - ) - val textSize = textLayout.size + instructionOffset.y + ( + if (isSpotlightCenterAbove) { + -16f + } else { + val instructionConstraints = Constraints( + maxWidth = (size.width - instructionOffset.x).toInt(), + ) + val textLayout = textMeasurer.measure( + stepInstruction, + instructionStyle, + overflow = TextOverflow.Visible, + constraints = instructionConstraints, + ) + val textSize = textLayout.size - textSize.height.toFloat() + 16f - }) + textSize.height.toFloat() + 16f + } + ) val lineStart = Offset(lineX, lineStartY) val lineEndY = sizeOffset.topLeft.y + (if (isSpotlightCenterAbove) sizeOffset.size.height + 32f else -32f) @@ -275,11 +279,11 @@ private fun DrawScope.spotlightAboveStepForwardOffset( Offset(if (isHorizontalBar) 0f else 32f, 0f), ) val x = referencePosition.position.x + - if (isHorizontalBar) { - 32f - } else { - referencePosition.size.width * 0.2f - } + if (isHorizontalBar) { + 32f + } else { + referencePosition.size.width * 0.2f + } val y = size.height * 0.6f return Offset(x, y) } diff --git a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt index 6523de45..f5d449f5 100644 --- a/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt +++ b/core/common/src/main/java/com/crisiscleanup/core/common/TutorialDirector.kt @@ -24,7 +24,7 @@ enum class TutorialStep { AccountInfo, ProvideAppFeedback, IncidentSelect, - End + End, } @Qualifier @@ -32,5 +32,5 @@ enum class TutorialStep { annotation class Tutorials(val director: CrisisCleanupTutorialDirectors) enum class CrisisCleanupTutorialDirectors { - Menu + Menu, } diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt index bedd2b9c..ed8f70ce 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/IncidentsRepository.kt @@ -18,6 +18,7 @@ interface IncidentsRepository { suspend fun getIncident(id: Long, loadFormFields: Boolean = false): Incident? suspend fun getIncidents(startAt: Instant): List + suspend fun getIncidentsList(): List fun streamIncident(id: Long): Flow diff --git a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt index 3204556f..c81bd4bf 100644 --- a/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt +++ b/core/data/src/main/java/com/crisiscleanup/core/data/repository/OfflineFirstIncidentsRepository.kt @@ -20,6 +20,7 @@ import com.crisiscleanup.core.database.model.asExternalModel import com.crisiscleanup.core.datastore.LocalAppPreferencesDataSource import com.crisiscleanup.core.model.data.INCIDENT_ORGANIZATIONS_STABLE_MODEL_BUILD_VERSION import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.IncidentIdNameType import com.crisiscleanup.core.network.CrisisCleanupNetworkDataSource import com.crisiscleanup.core.network.model.NetworkIncident import com.crisiscleanup.core.network.model.NetworkIncidentLocation @@ -83,6 +84,23 @@ class OfflineFirstIncidentsRepository @Inject constructor( .map(PopulatedIncident::asExternalModel) } + override suspend fun getIncidentsList(): List { + try { + return networkDataSource.getIncidentsList() + .map { + IncidentIdNameType( + it.id, + it.name, + it.shortName, + disasterLiteral = it.type, + ) + } + } catch (e: Exception) { + logger.logException(e) + } + return emptyList() + } + override fun streamIncident(id: Long) = incidentDao.streamFormFieldsIncident(id).mapLatest { it?.asExternalModel() } diff --git a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ListOptionsDropdown.kt b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ListOptionsDropdown.kt index 23d6e9f6..a521e399 100644 --- a/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ListOptionsDropdown.kt +++ b/core/designsystem/src/main/java/com/crisiscleanup/core/designsystem/component/ListOptionsDropdown.kt @@ -32,6 +32,7 @@ fun ListOptionsDropdown( Row( modifier .actionHeight() + // TODO Common dimensions .roundedOutline(radius = 3.dp) .clickable( onClick = onToggleDropdown, 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 b7dc7b33..0beeddcd 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 @@ -39,6 +39,7 @@ import androidx.compose.material.icons.filled.QuestionMark import androidx.compose.material.icons.filled.Remove import androidx.compose.material.icons.filled.Rotate90DegreesCcw import androidx.compose.material.icons.filled.Rotate90DegreesCw +import androidx.compose.material.icons.filled.Schedule import androidx.compose.material.icons.filled.SentimentNeutral import androidx.compose.material.icons.filled.UnfoldMore import androidx.compose.material.icons.filled.Visibility @@ -88,6 +89,7 @@ object CrisisCleanupIcons { val MyLocation = icons.MyLocation val Organization = icons.Domain val Person = icons.Person + val PendingRequestRedeploy = icons.Schedule val Phone = icons.Phone val PhotoGrid = icons.PhotoLibrary val Play = icons.PlayArrow 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 73ec43d6..719b5252 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 @@ -37,6 +37,7 @@ val primaryBlueOneTenthColor = primaryBlueColor.copy(alpha = 0.1f) val primaryRedColor = Color(0xFFED4747) val primaryOrangeColor = Color(0xFFF79820) val devActionColor = Color(0xFFF50057) +val green600 = Color(0xFF43A047) internal val crisisCleanupYellow100 = Color(0xFFFFDC68) internal val crisisCleanupYellow100HalfTransparent = crisisCleanupYellow100.copy(alpha = 0.5f) val survivorNoteColor = crisisCleanupYellow100HalfTransparent diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt index 78ed0285..8989e860 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/CrisisCleanupList.kt @@ -33,7 +33,7 @@ val EmptyList = CrisisCleanupList( shared = ListShare.Private, permission = ListPermission.Read, incidentId = EmptyIncident.id, - incident = IncidentIdNameType(id = EmptyIncident.id, "", "", ""), + incident = EmptyIncidentIdNameType, ) enum class ListModel(val literal: String) { diff --git a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt index fa4cd525..91779f6e 100644 --- a/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt +++ b/core/model/src/main/java/com/crisiscleanup/core/model/data/Incident.kt @@ -78,3 +78,13 @@ data class IncidentIdNameType( val disasterLiteral: String, val disaster: Disaster = disasterFromLiteral(disasterLiteral), ) + +val Incident.idNameType: IncidentIdNameType + get() = IncidentIdNameType( + id, + name, + shortName, + disasterLiteral, + ) + +val EmptyIncidentIdNameType = EmptyIncident.idNameType 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 f768a78e..fbd0fb68 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 @@ -14,6 +14,7 @@ import com.crisiscleanup.core.network.model.NetworkLocation 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.NetworkShortIncident import com.crisiscleanup.core.network.model.NetworkTeamResult import com.crisiscleanup.core.network.model.NetworkUserProfile import com.crisiscleanup.core.network.model.NetworkWorkTypeRequest @@ -39,6 +40,12 @@ interface CrisisCleanupNetworkDataSource { after: Instant? = null, ): List + suspend fun getIncidentsList( + fields: List = listOf("id", "name", "short_name", "incident_type"), + limit: Int = 250, + ordering: String = "-start_at", + ): List + suspend fun getIncidentLocations( locationIds: List, ): List diff --git a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt index 2b0fb305..c3dd0aee 100644 --- a/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt +++ b/core/network/src/main/java/com/crisiscleanup/core/network/model/NetworkIncident.kt @@ -99,3 +99,20 @@ data class NetworkIncidentFormField( val name: String, ) } + +@Serializable +data class NetworkIncidentsListResult( + val errors: List? = null, + val count: Int? = null, + val results: List? = null, +) + +@Serializable +data class NetworkShortIncident( + val id: Long, + val name: String, + @SerialName("short_name") + val shortName: String, + @SerialName("incident_type") + val type: String, +) 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 1f8dce9b..052baaee 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 @@ -7,6 +7,7 @@ import com.crisiscleanup.core.network.model.NetworkCountResult import com.crisiscleanup.core.network.model.NetworkFlagsFormData import com.crisiscleanup.core.network.model.NetworkFlagsFormDataResult import com.crisiscleanup.core.network.model.NetworkIncidentResult +import com.crisiscleanup.core.network.model.NetworkIncidentsListResult import com.crisiscleanup.core.network.model.NetworkIncidentsResult import com.crisiscleanup.core.network.model.NetworkLanguageTranslationResult import com.crisiscleanup.core.network.model.NetworkLanguagesResult @@ -76,6 +77,16 @@ private interface DataSourceApi { after: Instant?, ): NetworkIncidentsResult + @GET("incidents_list") + suspend fun getIncidentsList( + @Query("fields") + fields: String, + @Query("limit") + limit: Int, + @Query("sort") + ordering: String, + ): NetworkIncidentsListResult + @TokenAuthenticationHeader @GET("locations") suspend fun getLocations( @@ -330,6 +341,16 @@ class DataApiClient @Inject constructor( it.results ?: emptyList() } + override suspend fun getIncidentsList( + fields: List, + limit: Int, + ordering: String, + ) = networkApi.getIncidentsList(fields.joinToString(","), limit, ordering) + .let { + it.errors?.tryThrowException() + it.results ?: emptyList() + } + override suspend fun getIncidentLocations(locationIds: List) = networkApi.getLocations(locationIds.joinToString(","), locationIds.size) .let { diff --git a/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt b/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt index d748a09d..e9e66f06 100644 --- a/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt +++ b/core/ui/src/main/java/com/crisiscleanup/core/ui/LayoutSizePosition.kt @@ -11,4 +11,4 @@ data class LayoutSizePosition( ) val LayoutCoordinates.sizePosition: LayoutSizePosition - get() = LayoutSizePosition(size, positionInRoot()) \ No newline at end of file + get() = LayoutSizePosition(size, positionInRoot()) diff --git a/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt b/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt index 5713d5ac..ca40144b 100644 --- a/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt +++ b/core/ui/src/main/java/com/crisiscleanup/core/ui/TutorialViewTracker.kt @@ -5,4 +5,4 @@ import com.crisiscleanup.core.model.data.TutorialViewId interface TutorialViewTracker { val viewSizePositionLookup: SnapshotStateMap -} \ No newline at end of file +} diff --git a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/QueryIncidentsManager.kt b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/QueryIncidentsManager.kt index 2e884504..918229a0 100644 --- a/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/QueryIncidentsManager.kt +++ b/feature/caseeditor/src/main/java/com/crisiscleanup/feature/caseeditor/QueryIncidentsManager.kt @@ -5,6 +5,7 @@ import com.crisiscleanup.core.common.throttleLatest import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.model.data.Incident import com.crisiscleanup.core.model.data.IncidentIdNameType +import com.crisiscleanup.core.model.data.idNameType import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -42,12 +43,9 @@ class QueryIncidentsManager( private set private val allIncidentsShort = allIncidents.mapLatest { - val all = it.map { incident -> - with(incident) { - IncidentIdNameType(id, name, shortName, disasterLiteral) - } - } + val all = it.map(Incident::idNameType) + // TODO Redesign and separate state isLoadingAll.value = false all diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt index 838ea831..fc8be931 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/MenuTutorialDirector.kt @@ -34,4 +34,4 @@ class MenuTutorialDirector @Inject constructor() : TutorialDirector { tutorialStep.value = nextStep return nextStep != TutorialStep.End } -} \ No newline at end of file +} diff --git a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt index 17bce71c..87ac7cd5 100644 --- a/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt +++ b/feature/menu/src/main/java/com/crisiscleanup/feature/menu/di/MenuModule.kt @@ -12,11 +12,11 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -interface DataModule { +interface MenuModule { @Singleton @Binds @Tutorials(CrisisCleanupTutorialDirectors.Menu) fun bindsTutorialDirector( director: MenuTutorialDirector, ): TutorialDirector -} \ No newline at end of file +} diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt index 65d8f0ce..144bf692 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/RequestRedeployViewModel.kt @@ -15,13 +15,14 @@ import com.crisiscleanup.core.data.repository.AccountDataRefresher import com.crisiscleanup.core.data.repository.AccountDataRepository import com.crisiscleanup.core.data.repository.IncidentsRepository import com.crisiscleanup.core.data.repository.RequestRedeployRepository -import com.crisiscleanup.core.model.data.EmptyIncident -import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.model.data.EmptyIncidentIdNameType +import com.crisiscleanup.core.model.data.IncidentIdNameType import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -29,7 +30,7 @@ import javax.inject.Inject @HiltViewModel class RequestRedeployViewModel @Inject constructor( - incidentsRepository: IncidentsRepository, + private val incidentsRepository: IncidentsRepository, accountDataRepository: AccountDataRepository, accountDataRefresher: AccountDataRefresher, private val requestRedeployRepository: RequestRedeployRepository, @@ -37,21 +38,18 @@ class RequestRedeployViewModel @Inject constructor( @Logger(CrisisCleanupLoggers.Account) private val logger: AppLogger, @Dispatcher(CrisisCleanupDispatchers.IO) private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { - var requestedIncidentIds by mutableStateOf(emptySet()) - private set - + private val requestedIncidentsStream = MutableStateFlow>(emptySet()) + private val incidentsStream = MutableStateFlow?>(null) val viewState = combine( - incidentsRepository.incidents, + incidentsStream, accountDataRepository.accountData, - ::Pair, + requestedIncidentsStream, + ::Triple, ) - .mapLatest { (incidents, accountData) -> - val approvedIncidents = accountData.approvedIncidents - val incidentOptions = incidents - .filter { !approvedIncidents.contains(it.id) } - .toList() - .sortedByDescending(Incident::id) - RequestRedeployViewState.Ready(incidentOptions) + .filter { (incidents, _, _) -> incidents != null } + .mapLatest { (incidents, accountData, requestedIds) -> + val approvedIds = accountData.approvedIncidents + RequestRedeployViewState.Ready(incidents!!, approvedIds, requestedIds) } .stateIn( scope = viewModelScope, @@ -76,12 +74,14 @@ class RequestRedeployViewModel @Inject constructor( viewModelScope.launch(ioDispatcher) { accountDataRefresher.updateApprovedIncidents(true) - requestedIncidentIds = requestRedeployRepository.getRequestedIncidents() + requestedIncidentsStream.value = requestRedeployRepository.getRequestedIncidents() + + incidentsStream.value = incidentsRepository.getIncidentsList() } } - fun requestRedeploy(incident: Incident) { - if (incident == EmptyIncident) { + fun requestRedeploy(incident: IncidentIdNameType) { + if (incident == EmptyIncidentIdNameType) { return } @@ -116,6 +116,8 @@ class RequestRedeployViewModel @Inject constructor( sealed interface RequestRedeployViewState { data object Loading : RequestRedeployViewState data class Ready( - val incidents: List, + val incidents: List, + val approvedIncidentIds: Set, + val requestedIncidentIds: Set, ) : RequestRedeployViewState } diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/IncidentsDropdown.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/IncidentsDropdown.kt index 53d2ff87..89ac0dbd 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/IncidentsDropdown.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/IncidentsDropdown.kt @@ -2,62 +2,29 @@ package com.crisiscleanup.feature.organizationmanage.ui import androidx.compose.foundation.layout.width import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.key import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Size import androidx.compose.ui.platform.LocalDensity -import com.crisiscleanup.core.designsystem.theme.LocalFontStyles import com.crisiscleanup.core.designsystem.theme.listItemDropdownMenuOffset -import com.crisiscleanup.core.designsystem.theme.optionItemHeight -import com.crisiscleanup.core.model.data.Incident @Composable internal fun IncidentsDropdown( - incidents: List, contentSize: Size, showDropdown: Boolean, - onSelect: (Incident) -> Unit, onHideDropdown: () -> Unit, - isEditable: (Incident) -> Boolean = { true }, + optionsContent: @Composable () -> Unit, ) { - if (incidents.isNotEmpty()) { - DropdownMenu( - modifier = Modifier.width( - with(LocalDensity.current) { - contentSize.width.toDp().minus(listItemDropdownMenuOffset.x.times(2)) - }, - ), - expanded = showDropdown, - onDismissRequest = onHideDropdown, - offset = listItemDropdownMenuOffset, - ) { - IncidentOptions(incidents, onSelect, isEditable) - } - } -} - -@Composable -private fun IncidentOptions( - incidents: List, - onSelect: (Incident) -> Unit, - isEditable: (Incident) -> Boolean = { true }, -) { - for (incident in incidents) { - key(incident.id) { - DropdownMenuItem( - text = { - Text( - incident.name, - style = LocalFontStyles.current.header4, - ) - }, - onClick = { onSelect(incident) }, - modifier = Modifier.optionItemHeight(), - enabled = isEditable(incident), - ) - } + DropdownMenu( + modifier = Modifier.width( + with(LocalDensity.current) { + contentSize.width.toDp().minus(listItemDropdownMenuOffset.x.times(2)) + }, + ), + expanded = showDropdown, + onDismissRequest = onHideDropdown, + offset = listItemDropdownMenuOffset, + ) { + optionsContent() } } 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 c67aceb9..cbffd542 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 @@ -547,32 +547,58 @@ private fun NewOrganizationInput( val selectedIncident = incidentLookup[viewModel.selectedIncidentId] ?: EmptyIncident val selectIncidentHint = t("actions.select_incident") val incidents by viewModel.incidents.collectAsStateWithLifecycle() - Box( - Modifier - .listItemBottomPadding() - .fillMaxWidth(), - ) { - var showDropdown by remember { mutableStateOf(false) } - val onSelect = remember(viewModel) { - { incident: Incident -> - viewModel.selectedIncidentId = incident.id - showDropdown = false + if (incidents.isNotEmpty()) { + Box( + Modifier + .listItemBottomPadding() + .fillMaxWidth(), + ) { + var showDropdown by remember { mutableStateOf(false) } + val onSelect = remember(viewModel) { + { incident: Incident -> + viewModel.selectedIncidentId = incident.id + showDropdown = false + } + } + val onHideDropdown = remember(viewModel) { { showDropdown = false } } + ListOptionsDropdown( + text = selectedIncident.name.ifBlank { selectIncidentHint }, + isEditable = isEditable, + onToggleDropdown = { showDropdown = !showDropdown }, + modifier = Modifier.padding(16.dp), + dropdownIconContentDescription = selectIncidentHint, + ) { contentSize -> + IncidentsDropdown( + contentSize, + showDropdown, + onHideDropdown, + ) { + IncidentOptions( + incidents, + onSelect, + ) + } } } - val onHideDropdown = remember(viewModel) { { showDropdown = false } } - ListOptionsDropdown( - text = selectedIncident.name.ifBlank { selectIncidentHint }, - isEditable = isEditable, - onToggleDropdown = { showDropdown = !showDropdown }, - modifier = Modifier.padding(16.dp), - dropdownIconContentDescription = selectIncidentHint, - ) { contentSize -> - IncidentsDropdown( - incidents, - contentSize, - showDropdown, - onSelect, - onHideDropdown, + } +} + +@Composable +private fun IncidentOptions( + incidents: List, + onSelect: (Incident) -> Unit, +) { + for (incident in incidents) { + key(incident.id) { + DropdownMenuItem( + text = { + Text( + incident.name, + style = LocalFontStyles.current.header4, + ) + }, + onClick = { onSelect(incident) }, + modifier = Modifier.optionItemHeight(), ) } } diff --git a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt index c9fe2d93..7511ec91 100644 --- a/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt +++ b/feature/organizationmanage/src/main/java/com/crisiscleanup/feature/organizationmanage/ui/RequestRedeployScreen.kt @@ -4,15 +4,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +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.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.platform.testTag import androidx.compose.ui.unit.dp @@ -24,10 +29,15 @@ import com.crisiscleanup.core.designsystem.component.BusyIndicatorFloatingTopCen import com.crisiscleanup.core.designsystem.component.ListOptionsDropdown import com.crisiscleanup.core.designsystem.component.TopAppBarBackAction import com.crisiscleanup.core.designsystem.component.cancelButtonColors +import com.crisiscleanup.core.designsystem.icon.CrisisCleanupIcons +import com.crisiscleanup.core.designsystem.theme.LocalFontStyles +import com.crisiscleanup.core.designsystem.theme.green600 import com.crisiscleanup.core.designsystem.theme.listItemModifier import com.crisiscleanup.core.designsystem.theme.listItemSpacedBy -import com.crisiscleanup.core.model.data.EmptyIncident -import com.crisiscleanup.core.model.data.Incident +import com.crisiscleanup.core.designsystem.theme.listItemSpacedByHalf +import com.crisiscleanup.core.designsystem.theme.optionItemHeight +import com.crisiscleanup.core.model.data.EmptyIncidentIdNameType +import com.crisiscleanup.core.model.data.IncidentIdNameType import com.crisiscleanup.feature.organizationmanage.RequestRedeployViewModel import com.crisiscleanup.feature.organizationmanage.RequestRedeployViewState @@ -49,7 +59,7 @@ fun RequestRedeployRoute( ) if (isLoading) { - Box { + Box(Modifier.fillMaxSize()) { BusyIndicatorFloatingTopCenter(true) } } else if (isRedeployRequested) { @@ -69,11 +79,12 @@ fun RequestRedeployRoute( val isTransient by viewModel.isTransient.collectAsStateWithLifecycle(true) val isEditable = !isTransient val errorMessage = viewModel.redeployErrorMessage - val requestedIncidentIds = viewModel.requestedIncidentIds + val requestedIncidentIds = readyState.requestedIncidentIds + val approvedIncidentIds = readyState.approvedIncidentIds val isRequestingRedeploy by viewModel.isRequestingRedeploy.collectAsStateWithLifecycle() - var selectedIncident by remember { mutableStateOf(EmptyIncident) } + var selectedIncident by remember { mutableStateOf(EmptyIncidentIdNameType) } val onSelectIncident = remember(viewModel) { - { incident: Incident -> + { incident: IncidentIdNameType -> selectedIncident = incident } } @@ -83,6 +94,7 @@ fun RequestRedeployRoute( isEditable, incidents, requestedIncidentIds, + approvedIncidentIds, errorMessage, selectedIncident.name.ifBlank { selectIncidentHint }, selectIncidentHint, @@ -111,7 +123,7 @@ fun RequestRedeployRoute( .testTag("requestRedeploySubmitAction") .weight(1f), text = t("actions.submit"), - enabled = isEditable && selectedIncident != EmptyIncident, + enabled = isEditable && selectedIncident != EmptyIncidentIdNameType, indicateBusy = isRequestingRedeploy, onClick = { viewModel.requestRedeploy(selectedIncident) }, ) @@ -125,22 +137,17 @@ fun RequestRedeployRoute( @Composable private fun RequestRedeployContent( isEditable: Boolean, - incidents: List, + incidents: List, requestedIncidentIds: Set, + approvedIncidentIds: Set, errorMessage: String, selectedIncidentText: String, selectIncidentHint: String, - setSelectedIncident: (Incident) -> Unit, + setSelectedIncident: (IncidentIdNameType) -> Unit, rememberKey: Any, ) { val t = LocalAppTranslator.current - val isIncidentEditable = remember(requestedIncidentIds) { - { incident: Incident -> - !requestedIncidentIds.contains(incident.id) - } - } - Text( t("requestRedeploy.choose_an_incident"), listItemModifier, @@ -154,28 +161,81 @@ private fun RequestRedeployContent( ) } - var showDropdown by remember { mutableStateOf(false) } - val onSelectIncident = remember(rememberKey) { - { incident: Incident -> - setSelectedIncident(incident) - showDropdown = false + if (incidents.isNotEmpty()) { + var showDropdown by remember { mutableStateOf(false) } + val onSelectIncident = remember(rememberKey) { + { incident: IncidentIdNameType -> + setSelectedIncident(incident) + showDropdown = false + } + } + val onHideDropdown = remember(rememberKey) { { showDropdown = false } } + ListOptionsDropdown( + text = selectedIncidentText, + isEditable = isEditable, + onToggleDropdown = { showDropdown = !showDropdown }, + modifier = Modifier.padding(16.dp), + dropdownIconContentDescription = selectIncidentHint, + ) { contentSize -> + IncidentsDropdown( + contentSize, + showDropdown, + onHideDropdown, + ) { + IncidentOptions( + incidents, + approvedIncidentIds, + requestedIncidentIds, + onSelectIncident, + ) + } } } - val onHideDropdown = remember(rememberKey) { { showDropdown = false } } - ListOptionsDropdown( - text = selectedIncidentText, - isEditable = isEditable, - onToggleDropdown = { showDropdown = !showDropdown }, - modifier = Modifier.padding(16.dp), - dropdownIconContentDescription = selectIncidentHint, - ) { contentSize -> - IncidentsDropdown( - incidents, - contentSize, - showDropdown, - onSelectIncident, - onHideDropdown, - isEditable = isIncidentEditable, - ) +} + +@Composable +private fun IncidentOptions( + incidents: List, + approvedIds: Set, + requestedIds: Set, + onSelect: (IncidentIdNameType) -> Unit, +) { + val t = LocalAppTranslator.current + for (incident in incidents) { + key(incident.id) { + val isApproved = approvedIds.contains(incident.id) + val isRequested = requestedIds.contains(incident.id) + DropdownMenuItem( + text = { + Row( + horizontalArrangement = listItemSpacedByHalf, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + incident.name, + Modifier.weight(1f), + style = LocalFontStyles.current.header4, + ) + if (isApproved) { + Icon( + CrisisCleanupIcons.Check, + contentDescription = t("~~{incident_name} is already approved") + .replace("{incident_name}", incident.shortName), + tint = green600, + ) + } else if (isRequested) { + Icon( + CrisisCleanupIcons.PendingRequestRedeploy, + contentDescription = t("~~{incident_name} is already awaiting redeploy") + .replace("{incident_name}", incident.shortName), + ) + } + } + }, + onClick = { onSelect(incident) }, + modifier = Modifier.optionItemHeight(), + enabled = !(isApproved || isRequested), + ) + } } }