diff --git a/.github/workflows/generate-build.yml b/.github/workflows/generate-build.yml index a4ec909a83..7e98e5f852 100644 --- a/.github/workflows/generate-build.yml +++ b/.github/workflows/generate-build.yml @@ -103,10 +103,10 @@ jobs: run: echo ${{ secrets.RELEASE_STORE_FILE }} | base64 -d >> app/navi-release-key.jks - name: Build - APK - ${{ inputs.environment }}-${{ inputs.type }} if: inputs.output == 'APK' - run: ./gradlew package${{ inputs.environment }}${{ inputs.type }}UniversalApk -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} + run: ./gradlew package${{ inputs.environment }}${{ inputs.type }}UniversalApk -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} -POAUTH_WEB_CLIENT_ID=${{ secrets.OAUTH_WEB_CLIENT_ID }} - name: Build - AAB - ${{ inputs.environment }}-${{ inputs.type }} if: inputs.output == 'AAB' - run: ./gradlew :app:bundle${{ inputs.environment }}${{ inputs.type }} -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} + run: ./gradlew :app:bundle${{ inputs.environment }}${{ inputs.type }} -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} -POAUTH_WEB_CLIENT_ID=${{ secrets.OAUTH_WEB_CLIENT_ID }} - name: Upload - ${{ inputs.output }} - ${{ inputs.environment }}-${{ inputs.type }} uses: actions/upload-artifact@v4 with: diff --git a/.github/workflows/macrobenchmark.yml b/.github/workflows/macrobenchmark.yml index ccefa2a583..bf566476be 100644 --- a/.github/workflows/macrobenchmark.yml +++ b/.github/workflows/macrobenchmark.yml @@ -45,7 +45,7 @@ jobs: - name: Export Release Store File run: echo ${{ secrets.RELEASE_STORE_FILE }} | base64 -d >> app/navi-release-key.jks - name: Build - APK - app - run: ./gradlew packageQaReleaseUniversalApk -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} + run: ./gradlew packageQaReleaseUniversalApk -PRELEASE_STORE_PASSWORD=${{ secrets.RELEASE_STORE_PASSWORD }} -PRELEASE_KEY_ALIAS=${{ secrets.RELEASE_KEY_ALIAS }} -PRELEASE_KEY_PASSWORD=${{ secrets.RELEASE_KEY_PASSWORD }} -PBASE_URL=${{ secrets.BASE_URL }} -PADS_ANALYTICS_BASE_URL=${{ secrets.ADS_ANALYTICS_BASE_URL }} -PALFRED_API_KEY=${{ secrets.ALFRED_API_KEY }} -PAPPSFLYER_KEY=${{ secrets.APPSFLYER_KEY }} -PHYPERVERGE_APP_ID=${{ secrets.HYPERVERGE_APP_ID }} -PHYPERVERGE_APP_KEY=${{ secrets.HYPERVERGE_APP_KEY }} -PMQTT_PASSWORD=${{ secrets.MQTT_PASSWORD }} -PMQTT_USERNAME=${{ secrets.MQTT_USERNAME }} -PPULSE_BASE_URL=${{ secrets.PULSE_BASE_URL }} -PSSL_PINNING_KEY=${{ secrets.SSL_PINNING_KEY }} -PYOUTUBE_KEY=${{ secrets.YOUTUBE_KEY }} -PFACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} -PTRUECALLER_KEY=${{ secrets.TRUECALLER_KEY }} -PGI_RAZORPAY_KEY=${{ secrets.GI_RAZORPAY_KEY }} -PGOOGLE_MAPS_KEY=${{ secrets.GOOGLE_MAPS_KEY }} -PRECAPTCHA_KEY=${{ secrets.RECAPTCHA_KEY }} -PCODEPUSH_DEPLOYMENT_KEY=${{ secrets.CODEPUSH_DEPLOYMENT_KEY }} -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=${{ secrets.NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT }} -PNAVIPAY_SMV_BASE_URL=${{ secrets.NAVIPAY_SMV_BASE_URL }} -PNAVIPAY_SMV_CLIENT_ID=${{ secrets.NAVIPAY_SMV_CLIENT_ID }} -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=${{ secrets.NAVIPAY_CONVERSATION_ID_GENERATOR_SALT }} -POAUTH_WEB_CLIENT_ID=${{ secrets.OAUTH_WEB_CLIENT_ID }} - name: Build - APK - benchmark run: ./gradlew benchmark:assembleQaBenchmark - name: Authenticate Cloud SDK diff --git a/Dockerfile b/Dockerfile index a97593d136..ab5a41f183 100644 --- a/Dockerfile +++ b/Dockerfile @@ -45,6 +45,7 @@ RUN --mount=type=secret,id=RELEASE_STORE_PASSWORD \ --mount=type=secret,id=NAVIPAY_SMV_BASE_URL \ --mount=type=secret,id=NAVIPAY_SMV_CLIENT_ID \ --mount=type=secret,id=NAVIPAY_CONVERSATION_ID_GENERATOR_SALT \ + --mount=type=secret,id=OAUTH_WEB_CLIENT_ID \ cd $WORK_DIR/android && ./gradlew clean --no-configuration-cache :app:bundleProdRelease \ -PRELEASE_STORE_PASSWORD=$(cat /run/secrets/RELEASE_STORE_PASSWORD) \ -PRELEASE_KEY_ALIAS=$(cat /run/secrets/RELEASE_KEY_ALIAS) \ @@ -67,7 +68,8 @@ RUN --mount=type=secret,id=RELEASE_STORE_PASSWORD \ -PNAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT=$(cat /run/secrets/NAVIPAY_FIRESTORE_CUSTOMER_DATA_SALT) \ -PNAVIPAY_SMV_BASE_URL=$(cat /run/secrets/NAVIPAY_SMV_BASE_URL) \ -PNAVIPAY_SMV_CLIENT_ID=$(cat /run/secrets/NAVIPAY_SMV_CLIENT_ID) \ - -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=$(cat /run/secrets/NAVIPAY_CONVERSATION_ID_GENERATOR_SALT) + -PNAVIPAY_CONVERSATION_ID_GENERATOR_SALT=$(cat /run/secrets/NAVIPAY_CONVERSATION_ID_GENERATOR_SALT) \ + -POAUTH_WEB_CLIENT_ID=$(cat /run/secrets/OAUTH_WEB_CLIENT_ID) RUN --mount=type=secret,id=FLAVOR \ --mount=type=secret,id=NEXUS_URL \ diff --git a/android/app/src/prod/google-services.json b/android/app/src/prod/google-services.json index 990a8e5f5a..9355a23d68 100644 --- a/android/app/src/prod/google-services.json +++ b/android/app/src/prod/google-services.json @@ -6,42 +6,6 @@ "storage_bucket": "navi-76a9b.appspot.com" }, "client": [ - { - "client_info": { - "mobilesdk_app_id": "1:1074736393094:android:1b42658f489d11fd059a07", - "android_client_info": { - "package_name": "com.navi" - } - }, - "oauth_client": [ - { - "client_id": "1074736393094-u0ouuc378q74e5iagn36dse6nr3ph152.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDxK8u8ONNh3C2-3Xvvavosd2NNuI62wTk" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "1074736393094-u0ouuc378q74e5iagn36dse6nr3ph152.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "1074736393094-n0d5aeopsk3k7vdb9f1ms7mobhmq9jlk.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.gonavi.app" - } - } - ] - } - } - }, { "client_info": { "mobilesdk_app_id": "1:1074736393094:android:21cc2dbc71b97768059a07", @@ -59,47 +23,11 @@ } }, { - "client_id": "1074736393094-u0ouuc378q74e5iagn36dse6nr3ph152.apps.googleusercontent.com", - "client_type": 3 - } - ], - "api_key": [ - { - "current_key": "AIzaSyDxK8u8ONNh3C2-3Xvvavosd2NNuI62wTk" - } - ], - "services": { - "appinvite_service": { - "other_platform_oauth_client": [ - { - "client_id": "1074736393094-u0ouuc378q74e5iagn36dse6nr3ph152.apps.googleusercontent.com", - "client_type": 3 - }, - { - "client_id": "1074736393094-n0d5aeopsk3k7vdb9f1ms7mobhmq9jlk.apps.googleusercontent.com", - "client_type": 2, - "ios_info": { - "bundle_id": "com.gonavi.app" - } - } - ] - } - } - }, - { - "client_info": { - "mobilesdk_app_id": "1:1074736393094:android:9ced0c993b0e71b4059a07", - "android_client_info": { - "package_name": "com.naviapp.fps" - } - }, - "oauth_client": [ - { - "client_id": "1074736393094-lni6s872pm15hd4ta99mnsc9brekgr74.apps.googleusercontent.com", + "client_id": "1074736393094-icuja28qkg6prj4v0cs34amncal6ouaj.apps.googleusercontent.com", "client_type": 1, "android_info": { - "package_name": "com.naviapp.fps", - "certificate_hash": "7982ade408008dbe40e5cca019305551d68722ea" + "package_name": "com.naviapp", + "certificate_hash": "2c188fa2fa7bf18ad2d42dca8cde41e8752d705b" } }, { @@ -116,14 +44,15 @@ "appinvite_service": { "other_platform_oauth_client": [ { - "client_id": "1074736393094-u0ouuc378q74e5iagn36dse6nr3ph152.apps.googleusercontent.com", + "client_id": "103698928317506130531", "client_type": 3 }, { "client_id": "1074736393094-n0d5aeopsk3k7vdb9f1ms7mobhmq9jlk.apps.googleusercontent.com", "client_type": 2, "ios_info": { - "bundle_id": "com.gonavi.app" + "bundle_id": "com.gonavi.app", + "app_store_id": "1541152485" } } ] diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 5cf7032220..0e5dd6721f 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -4,6 +4,7 @@ accompanist-permissions = "0.37.0" accompanist-systemuicontroller = "0.17.0" android-flexbox = "3.0.0" android-gms-playServicesAds = "23.6.0" +android-gms-playServicesAuth = "21.3.0" android-gms-playServicesAuthApiPhone = "18.2.0" android-gms-playServicesLocation = "21.3.0" android-gms-playServicesMaps = "17.0.0" @@ -134,6 +135,7 @@ accompanist-systemuicontroller = { module = "com.google.accompanist:accompanist- android-flexbox = { module = "com.google.android.flexbox:flexbox", version.ref = "android-flexbox" } android-gms-playServicesAds = { module = "com.google.android.gms:play-services-ads", version.ref = "android-gms-playServicesAds" } +android-gms-playServicesAuth = { module = "com.google.android.gms:play-services-auth", version.ref = "android-gms-playServicesAuth" } android-gms-playServicesAuthApiPhone = { module = "com.google.android.gms:play-services-auth-api-phone", version.ref = "android-gms-playServicesAuthApiPhone" } android-gms-playServicesLocation = { module = "com.google.android.gms:play-services-location", version.ref = "android-gms-playServicesLocation" } android-gms-playServicesMaps = { module = "com.google.android.gms:play-services-maps", version.ref = "android-gms-playServicesMaps" } diff --git a/android/navi-bbps/build.gradle b/android/navi-bbps/build.gradle index 881c68deb3..c64b939727 100644 --- a/android/navi-bbps/build.gradle +++ b/android/navi-bbps/build.gradle @@ -50,19 +50,28 @@ android { qa { isDefault true dimension "app" + buildConfigField 'String', 'OAUTH_WEB_CLIENT_ID', formatString('679085041776-n6juhqpcr8221cifb7051ipkk2se4tpt.apps.googleusercontent.com') } prod { dimension "app" + if (project.hasProperty('OAUTH_WEB_CLIENT_ID')) { + buildConfigField 'String', 'OAUTH_WEB_CLIENT_ID', formatString("$OAUTH_WEB_CLIENT_ID") + } } } } +static def formatString(String value) { + return '"' + value + '"' +} + dependencies { implementation project(":navi-payments-shared") implementation project(":navi-payment") implementation project(":navi-rr") implementation libs.accompanist.systemuicontroller implementation libs.android.material + implementation libs.android.gms.playServicesAuth implementation libs.androidx.appcompat implementation libs.androidx.compose.material3 implementation libs.androidx.constraintlayout diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsAnalytics.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsAnalytics.kt index 33c11c244d..08d0c58a90 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsAnalytics.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsAnalytics.kt @@ -416,6 +416,7 @@ class NaviBbpsAnalytics private constructor() { initialSource: String, smsPermissionState: Boolean, originSessionAttributes: OriginSessionAttributes, + nuxType: String, ) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviBBPS_CategoryPage_OriginNux_AddBills_Clicked", @@ -426,6 +427,7 @@ class NaviBbpsAnalytics private constructor() { NAVI_BBPS_INITIAL_SOURCE to initialSource, "smsPermissionState" to smsPermissionState.toString(), "originWidgetStatus" to originSessionAttributes.originWidgetStatus.name, + "nuxType" to nuxType, ), ) } @@ -510,18 +512,80 @@ class NaviBbpsAnalytics private constructor() { ) } - fun onProjectOriginExperimentFetched( - initialSource: String, - isExperimentEnabled: Boolean, - isOriginNuxSeen: Boolean, - ) { + fun onProjectOriginExperimentFetched(initialSource: String, isExperimentEnabled: Boolean) { NaviTrackEvent.trackEventOnClickStream( eventName = "NaviBBPS_Dev_CategoryPage_Origin_Experiment_Result", eventValues = mapOf( NAVI_BBPS_INITIAL_SOURCE to initialSource, "isExperimentEnabled" to isExperimentEnabled.toString(), - "isOriginNuxSeen" to isOriginNuxSeen.toString(), + ), + ) + } + + fun onGmailAccessSignInCancelled( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_CategoryPage_GmailAccessSignIn_Cancelled", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun onGmailAccessSignInFailed( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_CategoryPage_GmailAccessSignIn_Failed", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun onOriginEmailExperimentEnabled( + initialSource: String, + source: String, + sessionAttribute: Map, + isOriginEmailSubExperimentEnabled: Boolean, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_Dev_CategoryPage_Origin_Email_Experiment", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + "isOriginEmailSubExperimentEnabled" to + isOriginEmailSubExperimentEnabled.toString(), + ), + ) + } + + fun onFullScreenLoaderBackClicked( + initialSource: String, + source: String, + sessionAttribute: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_Dev_CategoryPage_FullScreenLoaderBack_Clicked", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, ), ) } @@ -2550,6 +2614,86 @@ class NaviBbpsAnalytics private constructor() { ), ) } + + fun onGmailAccessSignInCancelled( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_MyBills_GmailAccessSignInCancelled", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun onGmailAccessSignInFailed( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_MyBills_GmailAccessSignInFailed", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun gmailNotInitialized( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_MyBills_GmailNotInitialized", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun gmailServerCredentialsMissing( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_MyBills_GmailServerCredentialsMissing", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun onFullScreenLoaderBackClicked( + initialSource: String, + source: String, + sessionAttribute: Map, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_MyBills_FullScreenLoader_Back_Clicked", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } } inner class MyBillHistoryDetails { @@ -3230,6 +3374,70 @@ class NaviBbpsAnalytics private constructor() { ), ) } + + fun onGmailAccessSignInCancelled( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_DetectedBills_GmailAccessSignInCancelled", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun onGmailAccessSignInFailed( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_DetectedBills_GmailAccessSignInFailed", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun gmailNotInitialized( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_DetectedBills_GmailNotInitialized", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } + + fun gmailServerCredentialsMissing( + source: String, + sessionAttribute: Map, + initialSource: String, + ) { + NaviTrackEvent.trackEventOnClickStream( + eventName = "NaviBBPS_DetectedBills_GmailServerCredentialsMissing", + eventValues = + mapOf( + NAVI_BBPS_SOURCE to source, + NAVI_BBPS_SESSION_ID to sessionAttribute[NAVI_BBPS_SESSION_ID].orEmpty(), + NAVI_BBPS_INITIAL_SOURCE to initialSource, + ), + ) + } } companion object { diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsConstants.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsConstants.kt index d26b070f15..e2f5cbbf1c 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsConstants.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/NaviBbpsConstants.kt @@ -105,7 +105,8 @@ const val KEY_BBPS_PHONE_SERIES_MAPPING_REFRESHED_TIMESTAMP = const val KEY_BBPS_ABTESTING_LAST_REFRESHED_TIMESTAMP = "KEY_BBPS_ABTESTING_LAST_REFRESHED_TIMESTAMP" const val KEY_BBPS_BANNERS_SEEN_MAP = "KEY_BBPS_BANNERS_SEEN_MAP" -const val KEY_BBPS_ORIGIN_NUX_SEEN = "KEY_BBPS_ORIGIN_NUX_SEEN" +const val KEY_BBPS_ORIGIN_SMS_NUX_SEEN = "KEY_BBPS_ORIGIN_NUX_SEEN" +const val KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN = "KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN" const val KEY_BBPS_ORIGIN_WIDGET = "KEY_BBPS_ORIGIN_WIDGET" // Lottie file name @@ -122,6 +123,7 @@ const val PAYMENT_MODE_UPI = "UPI" const val AB_EXPERIMENT_NAVIBBPS_FESTIVE_THEME = "festive-theme" const val AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME = "NaviBBPS-Project-Origin" const val AB_TESTING_CUSTOM_PREPAID_PLANS_EXPERIMENT_NAME = "NaviBBPS-Custom-Prepaid-Plans" +const val AB_TESTING_PROJECT_ORIGIN_EMAIL_SUB_EXPERIMENT_NAME = "NaviBBPS-Project-Origin-Email" // Navi Bbps Constants const val NAVI_PAY_NUDGE_DETAILS_AMOUNT = "1000.0" diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessResponse.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessResponse.kt new file mode 100644 index 0000000000..5c5c27f3f8 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessResponse.kt @@ -0,0 +1,29 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.gmail.model + +import android.accounts.Account +import com.google.android.gms.auth.api.signin.GoogleSignInAccount +import com.google.gson.annotations.SerializedName + +data class GmailAccessResponse( + @SerializedName("authCode") val authCode: String? = null, + @SerializedName("emailId") val emailId: String? = null, + @SerializedName("profileData") val profileData: GoogleUserProfileData? = null, + @SerializedName("signInAccount") val signInAccount: GoogleSignInAccount? = null, + @SerializedName("account") val account: Account? = null, +) + +data class GoogleUserProfileData( + @SerializedName("displayName") val displayName: String? = null, + @SerializedName("id") val id: String? = null, + @SerializedName("familyName") val familyName: String? = null, + @SerializedName("givenName") val givenName: String? = null, + @SerializedName("idToken") val idToken: String? = null, + @SerializedName("photoUrl") val photoUrl: String? = null, +) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessState.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessState.kt new file mode 100644 index 0000000000..eb41a88c81 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/model/GmailAccessState.kt @@ -0,0 +1,22 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.gmail.model + +sealed interface GmailAccessState { + data object NotInitialized : GmailAccessState + + data object ServerCredentialsMissing : GmailAccessState + + data class AccessGranted(val signInResponse: GmailAccessResponse) : GmailAccessState + + data class AuthorizationException(val exception: Exception) : GmailAccessState + + data object UserCancelled : GmailAccessState + + data object UnexpectedSignInResult : GmailAccessState +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/signin/GmailAccessSignInManager.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/signin/GmailAccessSignInManager.kt new file mode 100644 index 0000000000..f54932fd3f --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/gmail/signin/GmailAccessSignInManager.kt @@ -0,0 +1,148 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.gmail.signin + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.widget.Toast +import androidx.activity.compose.ManagedActivityResultLauncher +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.ActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.Composable +import com.google.android.gms.auth.api.signin.GoogleSignIn +import com.google.android.gms.auth.api.signin.GoogleSignInClient +import com.google.android.gms.auth.api.signin.GoogleSignInOptions +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.common.api.Scope +import com.navi.bbps.R +import com.navi.bbps.common.gmail.model.GmailAccessResponse +import com.navi.bbps.common.gmail.model.GmailAccessState +import com.navi.bbps.common.gmail.model.GoogleUserProfileData +import com.navi.common.utils.log +import dagger.hilt.android.qualifiers.ActivityContext +import dagger.hilt.android.scopes.ActivityScoped +import javax.inject.Inject + +@ActivityScoped +class GmailAccessSignInManager @Inject constructor(@ActivityContext val context: Context) { + + private var isInitialized: Boolean = false + private var requestedScopes: List = emptyList() + private lateinit var clientId: String + + fun initialize(clientId: String) { + this.clientId = clientId + isInitialized = true + } + + fun signIn( + launcher: ManagedActivityResultLauncher, + callback: (gmailAccessState: GmailAccessState) -> Unit, + ) { + if (isInitialized.not()) { + callback.invoke(GmailAccessState.NotInitialized) + return + } + if (clientId.isEmpty()) { + callback.invoke(GmailAccessState.ServerCredentialsMissing) + return + } + launcher.launch(getSignInClient().signInIntent) + } + + private fun getSignInClient(): GoogleSignInClient { + return GoogleSignIn.getClient( + context, + GoogleSignInOptions.Builder(GoogleSignInOptions.DEFAULT_SIGN_IN) + .requestServerAuthCode(clientId) + .requestEmail() + .requestScopes( + Scope(GMAIL_READ_ONLY_SCOPE), + Scope(USER_PROFILE_SCOPE), + Scope(USER_EMAIL_SCOPE), + *requestedScopes.toTypedArray(), + ) + .build(), + ) + } + + @Composable + fun createSignInResultLauncher( + callback: (gmailAccessState: GmailAccessState) -> Unit + ): ManagedActivityResultLauncher { + return rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + when (it.resultCode) { + Activity.RESULT_OK -> { + try { + val taskResult = processSignInResult(it.data) + callback.invoke(GmailAccessState.AccessGranted(taskResult)) + } catch (e: Exception) { + e.log() + callback.invoke(GmailAccessState.AuthorizationException(e)) + Toast.makeText( + context, + R.string.bbps_email_verification_failed, + Toast.LENGTH_SHORT, + ) + .show() + } + } + Activity.RESULT_CANCELED -> { + callback.invoke(GmailAccessState.UserCancelled) + Toast.makeText( + context, + R.string.bbps_email_verification_failed, + Toast.LENGTH_SHORT, + ) + .show() + } + else -> { + Toast.makeText( + context, + R.string.bbps_email_verification_failed, + Toast.LENGTH_SHORT, + ) + .show() + callback.invoke(GmailAccessState.UnexpectedSignInResult) + } + } + } + } + + private fun processSignInResult(intent: Intent?): GmailAccessResponse { + return try { + val task = GoogleSignIn.getSignedInAccountFromIntent(intent) + val taskResult = task.getResult(ApiException::class.java) + GmailAccessResponse( + authCode = taskResult.serverAuthCode, + emailId = taskResult.account?.name, + profileData = + GoogleUserProfileData( + displayName = taskResult.displayName, + id = taskResult.id, + familyName = taskResult.familyName, + givenName = taskResult.givenName, + idToken = taskResult.idToken, + photoUrl = taskResult.photoUrl?.toString().orEmpty(), + ), + signInAccount = taskResult, + account = taskResult.account, + ) + } catch (e: ApiException) { + throw e + } + } + + companion object { + private const val GMAIL_READ_ONLY_SCOPE = "https://www.googleapis.com/auth/gmail.readonly" + private const val USER_PROFILE_SCOPE = "https://www.googleapis.com/auth/userinfo.profile" + private const val USER_EMAIL_SCOPE = "https://www.googleapis.com/auth/userinfo.email" + } +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/mapper/DetectedBillsMapper.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/mapper/DetectedBillsMapper.kt index 74da3406f6..bcda1f12d7 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/mapper/DetectedBillsMapper.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/mapper/DetectedBillsMapper.kt @@ -7,6 +7,7 @@ package com.navi.bbps.common.mapper +import com.navi.base.utils.orFalse import com.navi.bbps.common.model.network.DetectedBillsResponse import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.model.view.DetectedBillSource @@ -14,20 +15,28 @@ import com.navi.bbps.common.model.view.DetectedBillStatus import javax.inject.Inject class DetectedBillsMapper @Inject constructor() { - fun map(detectedBillsResponse: DetectedBillsResponse): List { - return detectedBillsResponse.bills?.map { + fun map( + detectedBillsResponse: DetectedBillsResponse, + existingBills: List = emptyList(), + ): List { + return detectedBillsResponse.bills?.map { responseItem -> + val existingBill = + existingBills.find { it.detectedBillId == responseItem.detectedBillId } + DetectedBillEntity( - detectedBillId = it.detectedBillId, - billerId = it.billerId, - billerName = it.billerName, - billerLogo = it.billerLogo.orEmpty(), - accountHolderName = it.accountHolderName.orEmpty(), - primaryCustomerParamValue = it.primaryCustomerParamValue.orEmpty(), - customerParams = it.customerParams.orEmpty(), - detectedBillSource = DetectedBillSource.fromString(it.billSource.orEmpty()), - categoryId = it.categoryId, - categoryTitle = it.categoryTitle, - detectedBillStatus = DetectedBillStatus.fromString(it.status.orEmpty()), + detectedBillId = responseItem.detectedBillId, + billerId = responseItem.billerId, + billerName = responseItem.billerName, + billerLogo = responseItem.billerLogo.orEmpty(), + accountHolderName = responseItem.accountHolderName.orEmpty(), + primaryCustomerParamValue = responseItem.primaryCustomerParamValue.orEmpty(), + customerParams = responseItem.customerParams.orEmpty(), + detectedBillSource = + DetectedBillSource.fromString(responseItem.billSource.orEmpty()), + categoryId = responseItem.categoryId, + categoryTitle = responseItem.categoryTitle, + detectedBillStatus = DetectedBillStatus.fromString(responseItem.status.orEmpty()), + isBillSeen = existingBill?.isBillSeen.orFalse(), ) } ?: emptyList() } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/config/NaviBbpsDefaultConfig.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/config/NaviBbpsDefaultConfig.kt index 0615f32792..f41efacb65 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/config/NaviBbpsDefaultConfig.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/config/NaviBbpsDefaultConfig.kt @@ -49,6 +49,7 @@ data class NaviBbpsDefaultConfig( val detectedBillsPollingTimeoutMillis: Long = 30000, @SerializedName("detectedBillsPollingWaitTimeMillis") val detectedBillsPollingWaitTimeMillis: Long = 2000, + @SerializedName("detectedBillsPollingMaxCount") val detectedBillsPollingMaxCount: Int = 3, val billAlreadyPaidErrorCodes: List = listOf("INVAL003"), ) { data class PaymentCheckoutConfigItem( diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/BillDetectionResponse.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/BillDetectionResponse.kt new file mode 100644 index 0000000000..91b084a6a1 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/BillDetectionResponse.kt @@ -0,0 +1,22 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.model.network + +import com.google.gson.annotations.SerializedName + +data class BillDetectionRequest( + @SerializedName("permission") val permission: String, + @SerializedName("googleToken") val googleToken: String?, + @SerializedName("emailId") val emailId: String?, +) + +data class BillDetectionResponse(@SerializedName("status") val status: String) + +fun BillDetectionResponse.isSuccess(): Boolean { + return status == "SUCCESS" +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/DetectedBillsResponse.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/DetectedBillsResponse.kt index 3020cf3ded..f1e1d4f7ac 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/DetectedBillsResponse.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/network/DetectedBillsResponse.kt @@ -9,18 +9,10 @@ package com.navi.bbps.common.model.network import com.google.gson.annotations.SerializedName -data class FetchDetectedBillsRequest( - @SerializedName("permissions") val permissions: List, - @SerializedName("requestId") val requestId: String?, - @SerializedName("fetchLatestBills") val fetchLatestBills: Boolean, - @SerializedName("isConsentProvided") val isConsentProvided: Boolean, -) - data class DetectedBillsResponse( @SerializedName("bills") val bills: List?, - @SerializedName("requestId") val requestId: String?, - @SerializedName("status") val status: String?, - @SerializedName("isFirstTimeUser") val isFirstTimeUser: Boolean?, + @SerializedName("isSmsConsentProvided") val isSmsConsentProvided: Boolean, + @SerializedName("isEmailConsentProvided") val isEmailConsentProvided: Boolean, ) data class DetectedBillItem( @@ -35,8 +27,5 @@ data class DetectedBillItem( @SerializedName("categoryId") val categoryId: String, @SerializedName("categoryTitle") val categoryTitle: String, @SerializedName("status") val status: String?, + @SerializedName("detectedOn") val detectedOn: String?, ) - -enum class DetectedBillPollingStatus { - DETECTED_BILL_FETCH_STATUS_PENDING -} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/view/DetectedBillEntity.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/view/DetectedBillEntity.kt index 6f38f0b0b2..221a12ca63 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/view/DetectedBillEntity.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/model/view/DetectedBillEntity.kt @@ -36,6 +36,7 @@ data class DetectedBillEntity( val categoryId: String, val categoryTitle: String, @TypeConverters(DetectedBillStatusConverter::class) val detectedBillStatus: DetectedBillStatus, + val isBillSeen: Boolean, ) { @IgnoredOnParcel @delegate:Ignore diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/ui/NaviBbpsCommonComposable.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/ui/NaviBbpsCommonComposable.kt index 4ebae36aa3..883ab8e05a 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/ui/NaviBbpsCommonComposable.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/ui/NaviBbpsCommonComposable.kt @@ -133,6 +133,7 @@ import com.navi.bbps.common.model.view.RefreshBillState import com.navi.bbps.common.theme.NaviBbpsColor import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getIndexedItemBgColor import com.navi.bbps.common.utils.NaviBbpsCommonUtils.isRechargeCategory +import com.navi.bbps.common.utils.OriginWidgetStatus import com.navi.bbps.common.utils.getDisplayableAmount import com.navi.bbps.common.utils.getImageRequestBuilder import com.navi.bbps.feature.contactlist.model.view.PhoneContactEntity @@ -2157,7 +2158,7 @@ fun OutlineRoundedThemeButtonPreview() { fun OriginLandingWidget( modifier: Modifier = Modifier, onAddClicked: () -> Unit, - isFirstTimeUser: Boolean, + originWidgetStatus: OriginWidgetStatus, detectedBills: List, ) { Box( @@ -2182,63 +2183,89 @@ fun OriginLandingWidget( verticalAlignment = Alignment.CenterVertically, ) { Column(modifier = Modifier.weight(1f)) { - if (isFirstTimeUser || detectedBills.isEmpty()) { - NaviText( - text = - buildAnnotatedString { - withStyle(style = SpanStyle(color = NaviBbpsColor.textPrimary)) { - append( - stringResource( - id = R.string.bbps_origin_add_bills_title_prefix - ) + SPACE - ) - } - withStyle( - style = SpanStyle(color = NaviBbpsColor.onSurfaceHighlight) - ) { - append( - stringResource( - id = R.string.bbps_origin_add_bills_title_suffix + when (originWidgetStatus) { + OriginWidgetStatus.SMS_FTU -> { + NaviText( + text = + buildAnnotatedString { + withStyle( + style = SpanStyle(color = NaviBbpsColor.textPrimary) + ) { + append( + stringResource( + id = R.string.bbps_origin_add_bills_title_prefix + ) + SPACE ) - ) - } - }, - fontSize = 14.sp, - lineHeight = 22.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - color = NaviBbpsColor.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - NaviText( - text = - pluralStringResource( - id = R.plurals.bbps_origin_x_bill_awaiting_title, - count = detectedBills.size, - formatArgs = arrayOf(detectedBills.size), - ), - fontSize = 14.sp, - lineHeight = 22.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - color = NaviBbpsColor.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + } + withStyle( + style = SpanStyle(color = NaviBbpsColor.onSurfaceHighlight) + ) { + append( + stringResource( + id = R.string.bbps_origin_add_bills_title_suffix + ) + ) + } + }, + fontSize = 14.sp, + lineHeight = 22.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + OriginWidgetStatus.EMAIL_FTU -> { + NaviText( + text = stringResource(id = R.string.bbps_origin_email_pending_title), + fontSize = 14.sp, + lineHeight = 22.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + else -> { + NaviText( + text = + pluralStringResource( + id = R.plurals.bbps_origin_x_bill_awaiting_title, + count = detectedBills.size, + formatArgs = arrayOf(detectedBills.size), + ), + fontSize = 14.sp, + lineHeight = 22.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } Spacer(modifier = Modifier.height(2.dp)) NaviText( text = - if (isFirstTimeUser) - stringResource(id = R.string.bbps_origin_add_bills_description) - else - stringResource( - id = R.string.bbps_origin_continue_add_bills_description - ), + when (originWidgetStatus) { + OriginWidgetStatus.SMS_FTU, + OriginWidgetStatus.EMAIL_FTU -> + stringResource(id = R.string.bbps_origin_add_bills_description) + + OriginWidgetStatus.SMS_RTU, + OriginWidgetStatus.EMAIL_RTU -> + stringResource( + id = R.string.bbps_origin_continue_add_bills_description + ) + + else -> EMPTY + }, fontSize = 12.sp, lineHeight = 16.sp, fontFamily = naviFontFamily, @@ -2263,8 +2290,19 @@ fun OriginLandingWidget( .padding(horizontal = 16.dp, vertical = 8.dp), horizontalArrangement = Arrangement.Center, ) { + if (originWidgetStatus == OriginWidgetStatus.EMAIL_FTU) { + Image( + painter = painterResource(id = R.drawable.ic_bbps_google_logo), + contentDescription = null, + modifier = Modifier.size(16.dp).align(Alignment.CenterVertically), + ) + Spacer(modifier = Modifier.width(8.dp)) + } NaviText( - text = stringResource(id = R.string.bbps_add_now), + text = + if (originWidgetStatus == OriginWidgetStatus.EMAIL_FTU) + stringResource(id = R.string.bbps_sign_in_with_google) + else stringResource(id = R.string.bbps_add_now), fontSize = 12.sp, lineHeight = 18.sp, fontFamily = naviFontFamily, @@ -2278,14 +2316,15 @@ fun OriginLandingWidget( Image( painter = - if (isFirstTimeUser) painterResource(id = R.drawable.ic_bbps_origin_add_bills) - else painterResource(id = R.drawable.ic_bbps_origin_add_remaining_bills), + if (originWidgetStatus == OriginWidgetStatus.SMS_FTU) + painterResource(id = R.drawable.ic_bbps_origin_add_bills) + else painterResource(id = R.drawable.ic_bbps_email_ftu_icon), contentDescription = "", modifier = Modifier.size(96.dp), ) } - if (isFirstTimeUser) { + if (originWidgetStatus == OriginWidgetStatus.SMS_FTU) { NaviText( text = stringResource(id = R.string.bbps_origin_sms_consent_text), modifier = Modifier.align(Alignment.BottomEnd).padding(end = 16.dp, bottom = 4.dp), @@ -2304,5 +2343,9 @@ fun OriginLandingWidget( @Preview @Composable fun OriginLandingWidgetPreview() { - OriginLandingWidget(onAddClicked = {}, isFirstTimeUser = true, detectedBills = listOf()) + OriginLandingWidget( + onAddClicked = {}, + originWidgetStatus = OriginWidgetStatus.SMS_FTU, + detectedBills = emptyList(), + ) } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/usecase/OriginExperimentUtils.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/usecase/OriginExperimentUtils.kt new file mode 100644 index 0000000000..9c11b335c5 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/usecase/OriginExperimentUtils.kt @@ -0,0 +1,42 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.usecase + +import com.navi.bbps.common.AB_TESTING_PROJECT_ORIGIN_EMAIL_SUB_EXPERIMENT_NAME +import com.navi.bbps.common.AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME +import com.navi.common.usecase.LitmusExperimentsUseCase +import javax.inject.Inject +import kotlin.time.Duration.Companion.hours + +class OriginExperimentUtils +@Inject +constructor(private val litmusExperimentsUseCase: LitmusExperimentsUseCase) { + private val originExperimentTtl = 6.hours.inWholeMilliseconds + + suspend fun isOriginExperimentEnabled(): Boolean { + return litmusExperimentsUseCase + .execute( + experimentName = AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME, + ttl = originExperimentTtl, + ) + ?.variant + ?.enabled == true + } + + suspend fun isOriginEmailSubExperimentEnabled(): Boolean { + val isExperimentEnabled = + litmusExperimentsUseCase + .execute( + experimentName = AB_TESTING_PROJECT_ORIGIN_EMAIL_SUB_EXPERIMENT_NAME, + ttl = originExperimentTtl, + ) + ?.variant + ?.enabled == true + return isExperimentEnabled + } +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandler.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandler.kt index 6ebeff029c..800877405e 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandler.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandler.kt @@ -7,38 +7,43 @@ package com.navi.bbps.common.utils -import com.navi.base.utils.orTrue import com.navi.bbps.common.BbpsSharedPreferences -import com.navi.bbps.common.KEY_BBPS_ORIGIN_NUX_SEEN +import com.navi.bbps.common.KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN +import com.navi.bbps.common.KEY_BBPS_ORIGIN_SMS_NUX_SEEN import com.navi.bbps.common.KEY_BBPS_ORIGIN_WIDGET import com.navi.bbps.common.NaviBbpsScreen import com.navi.bbps.common.mapper.DetectedBillsMapper import com.navi.bbps.common.model.network.DetectedBillsResponse -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo import com.navi.bbps.feature.detectedbills.DetectedBillsRepository import com.navi.common.network.models.isSuccessWithData import javax.inject.Inject +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext enum class OriginWidgetStatus { - FTUE, - RTUE, + SMS_FTU, + SMS_RTU, + EMAIL_FTU, + EMAIL_RTU, HIDDEN, } data class OriginSessionAttributes( val originWidgetStatus: OriginWidgetStatus, val detectedBills: List, - val isOriginNuxSeen: Boolean, + val isOriginSmsNuxSeen: Boolean = true, + val isOriginEmailNuxSeen: Boolean = true, ) { companion object { fun empty() = OriginSessionAttributes( originWidgetStatus = OriginWidgetStatus.HIDDEN, detectedBills = emptyList(), - isOriginNuxSeen = true, + isOriginSmsNuxSeen = true, + isOriginEmailNuxSeen = true, ) } } @@ -50,80 +55,115 @@ constructor( private val bbpsSharedPreferences: BbpsSharedPreferences, private val detectedBillsMapper: DetectedBillsMapper, ) { - suspend fun getOriginSessionAttributes(screen: NaviBbpsScreen): OriginSessionAttributes { - val isOriginNuxSeen = isOriginNuxSeen() - val cachedBills = detectedBillsRepository.getCachedDetectedBills() - - if (cachedBills.isEmpty()) { - setOriginWidget(OriginWidgetStatus.HIDDEN) - } - - return fetchAndUpdateOriginSession(screen, isOriginNuxSeen) + private suspend fun getOriginSessionAttributes( + screen: NaviBbpsScreen + ): OriginSessionAttributes { + return fetchAndUpdateOriginSession(screen) } - private fun isOriginNuxSeen() = - bbpsSharedPreferences.getBoolean(KEY_BBPS_ORIGIN_NUX_SEEN, false) + suspend fun getOriginAttributes( + screen: NaviBbpsScreen, + isOriginEmailSubExperimentEnabled: Boolean, + ): OriginSessionAttributes = + withContext(Dispatchers.IO) { + val attributes = getOriginSessionAttributes(screen) + val updatedAttributes = + if ( + attributes.originWidgetStatus == OriginWidgetStatus.EMAIL_FTU && + !isOriginEmailSubExperimentEnabled + ) { + attributes.copy(originWidgetStatus = OriginWidgetStatus.HIDDEN) + } else { + attributes + } + updatedAttributes + } + + private fun isOriginNuxSeen(detectedBillSource: DetectedBillSource): Boolean { + return when (detectedBillSource) { + DetectedBillSource.EMAIL -> { + bbpsSharedPreferences.getBoolean(KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN, false) + } + DetectedBillSource.SMS -> { + bbpsSharedPreferences.getBoolean(KEY_BBPS_ORIGIN_SMS_NUX_SEEN, false) + } + DetectedBillSource.UNKNOWN -> false + } + } + + fun setOriginNuxSeen(detectedBillSource: DetectedBillSource) { + when (detectedBillSource) { + DetectedBillSource.EMAIL -> { + bbpsSharedPreferences.saveBoolean(KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN, true) + } + DetectedBillSource.SMS -> { + bbpsSharedPreferences.saveBoolean(KEY_BBPS_ORIGIN_SMS_NUX_SEEN, true) + } + DetectedBillSource.UNKNOWN -> Unit + } + } private suspend fun fetchAndUpdateOriginSession( - screen: NaviBbpsScreen, - isOriginNuxSeen: Boolean, + screen: NaviBbpsScreen ): OriginSessionAttributes { val response = detectedBillsRepository.fetchDetectedBills( - fetchDetectedBillsRequest = - FetchDetectedBillsRequest( - permissions = listOf(DetectedBillSource.SMS.name), - requestId = null, - fetchLatestBills = false, - isConsentProvided = false, - ), - metricInfo = getBbpsMetricInfo(screenName = screen.screenName, isNae = { false }), + metricInfo = getBbpsMetricInfo(screenName = screen.screenName, isNae = { false }) ) return if (response.isSuccessWithData()) { - handleSuccessfulResponse(screen, response.data!!, isOriginNuxSeen) + response.data?.let { data -> handleSuccessfulResponse(data) } + ?: OriginSessionAttributes.empty() } else { OriginSessionAttributes.empty() } } - private fun handleSuccessfulResponse( - screen: NaviBbpsScreen, - detectedBillsResponse: DetectedBillsResponse, - isOriginNuxSeen: Boolean, + private suspend fun handleSuccessfulResponse( + detectedBillsResponse: DetectedBillsResponse ): OriginSessionAttributes { - return when { - detectedBillsResponse.isFirstTimeUser.orTrue() -> { - updateWidgetStatus(OriginWidgetStatus.FTUE) - OriginSessionAttributes( - originWidgetStatus = OriginWidgetStatus.FTUE, - detectedBills = emptyList(), - isOriginNuxSeen = isOriginNuxSeen, - ) - } + val isSmsConsentProvided = detectedBillsResponse.isSmsConsentProvided + val isEmailConsentProvided = detectedBillsResponse.isEmailConsentProvided + val detectedBillsFromNetwork = detectedBillsResponse.bills.orEmpty() - detectedBillsResponse.bills.isNullOrEmpty() -> { - updateWidgetStatus(OriginWidgetStatus.HIDDEN) - OriginSessionAttributes( - originWidgetStatus = OriginWidgetStatus.HIDDEN, - detectedBills = emptyList(), - isOriginNuxSeen = true, - ) - } + val isSmsNuxSeen = isSmsConsentProvided || isOriginNuxSeen(DetectedBillSource.SMS) + val isEmailNuxSeen = isEmailConsentProvided || isOriginNuxSeen(DetectedBillSource.EMAIL) - else -> { - updateWidgetStatus(OriginWidgetStatus.RTUE) - OriginSessionAttributes( - originWidgetStatus = OriginWidgetStatus.RTUE, - detectedBills = detectedBillsMapper.map(detectedBillsResponse), - isOriginNuxSeen = true, - ) + val (status, detectedBills) = + when { + !isSmsConsentProvided -> OriginWidgetStatus.SMS_FTU to emptyList() + + detectedBillsFromNetwork.isEmpty() && + isSmsConsentProvided && + !isEmailConsentProvided -> OriginWidgetStatus.EMAIL_FTU to emptyList() + + detectedBillsFromNetwork.isNotEmpty() && + isSmsConsentProvided && + !isEmailConsentProvided -> + OriginWidgetStatus.SMS_RTU to detectedBillsMapper.map(detectedBillsResponse) + + detectedBillsFromNetwork.isNotEmpty() && + isSmsConsentProvided && + isEmailConsentProvided -> + OriginWidgetStatus.EMAIL_RTU to detectedBillsMapper.map(detectedBillsResponse) + + else -> + OriginWidgetStatus.HIDDEN to + detectedBillsMapper.map( + detectedBillsResponse, + existingBills = detectedBillsRepository.getCachedDetectedBills(), + ) } - }.also { - if (screen == NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES_V2) { - bbpsSharedPreferences.saveBoolean(KEY_BBPS_ORIGIN_NUX_SEEN, true) - } - } + detectedBillsRepository.saveDetectedBillsToLocalDb(detectedBills = detectedBills) + + updateWidgetStatus(status) + + return OriginSessionAttributes( + originWidgetStatus = status, + detectedBills = detectedBills, + isOriginSmsNuxSeen = isSmsNuxSeen, + isOriginEmailNuxSeen = isEmailNuxSeen, + ) } private fun updateWidgetStatus(status: OriginWidgetStatus) { @@ -131,8 +171,4 @@ constructor( } fun setOriginWidget(status: OriginWidgetStatus) = updateWidgetStatus(status) - - fun setOriginNuxSeen() { - bbpsSharedPreferences.saveBoolean(KEY_BBPS_ORIGIN_NUX_SEEN, true) - } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/NaviBbpsExt.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/NaviBbpsExt.kt index 8cd52a93e9..b9939f4149 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/NaviBbpsExt.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/utils/NaviBbpsExt.kt @@ -33,9 +33,11 @@ import com.navi.base.utils.COMMA import com.navi.base.utils.EMPTY import com.navi.bbps.common.SYMBOL_RUPEE import com.navi.bbps.common.TWEEN_DURATION_IN_MILLIS +import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.theme.NaviBbpsColor import com.navi.bbps.common.uitron.deserializer.BBPSUiTronActionDeserializer import com.navi.bbps.common.uitron.serializer.BBPSUiTronActionSerializer +import com.navi.bbps.feature.detectedbills.DetectedBillsRepository import com.navi.common.utils.CommonUtils.formattedCurrency import com.navi.common.utils.registerUiTronDeSerializers import com.navi.common.utils.registerUiTronSerializer @@ -137,3 +139,19 @@ fun ColumnScope.ShadowStrip() { ) } } + +suspend fun DetectedBillsRepository.getApiBillsWithSeenStatusApplied( + detectedBillsFromNetwork: List +): List { + val localDetectedBills = getCachedDetectedBills() + val localMap = localDetectedBills.associateBy { it.detectedBillId } + + return detectedBillsFromNetwork.map { apiBill -> + val localBill = localMap[apiBill.detectedBillId] + if (localBill?.isBillSeen == true) { + apiBill.copy(isBillSeen = true) + } else { + apiBill + } + } +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandler.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandler.kt index 2a774a6a95..d0f9e9458e 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandler.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandler.kt @@ -7,21 +7,25 @@ package com.navi.bbps.common.viewmodel -import androidx.lifecycle.viewModelScope import com.navi.base.utils.NaviNetworkConnectivity import com.navi.base.utils.ResourceProvider -import com.navi.base.utils.orZero +import com.navi.base.utils.TrustedTimeAccessor +import com.navi.base.utils.log +import com.navi.base.utils.orFalse +import com.navi.base.utils.retry import com.navi.bbps.R import com.navi.bbps.common.ADDING_BILLS_SUCCESS_LOTTIE import com.navi.bbps.common.ADDING_DETECTED_BILLS_LOTTIE +import com.navi.bbps.common.DEFAULT_RETRY_COUNT import com.navi.bbps.common.ERROR_CODE_ORIGIN_NOTIFY_LATER +import com.navi.bbps.common.RETRY_INTERVAL_IN_SECONDS import com.navi.bbps.common.mapper.DetectedBillsMapper import com.navi.bbps.common.model.config.NaviBbpsDefaultConfig +import com.navi.bbps.common.model.network.BillDetectionRequest import com.navi.bbps.common.model.network.BillerInfo -import com.navi.bbps.common.model.network.DetectedBillPollingStatus import com.navi.bbps.common.model.network.DetectedBillsResponse -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsRequest +import com.navi.bbps.common.model.network.isSuccess import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.FullScreenLoaderState @@ -29,18 +33,18 @@ import com.navi.bbps.common.model.view.NaviBbpsButtonAction import com.navi.bbps.common.model.view.NaviBbpsButtonTheme import com.navi.bbps.common.model.view.NaviBbpsErrorButtonConfig import com.navi.bbps.common.model.view.NaviBbpsErrorConfig -import com.navi.bbps.common.utils.BbpsOriginSessionHandler import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo -import com.navi.bbps.common.utils.OriginWidgetStatus import com.navi.bbps.common.utils.refresh import com.navi.bbps.feature.customerinput.model.network.DeviceDetails import com.navi.bbps.feature.detectedbills.DetectedBillsRepository import com.navi.bbps.feature.detectedbills.model.view.NewAddedBills import com.navi.bbps.feature.mybills.MyBillsSyncJob +import com.navi.common.di.CoroutineDispatcherProvider import com.navi.common.network.models.RepoResult import com.navi.common.network.models.isSuccessWithData import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -51,6 +55,7 @@ import kotlinx.coroutines.launch class OriginBillDetectionHandler @Inject constructor( + private val coroutineDispatcherProvider: CoroutineDispatcherProvider, private val detectedBillsRepository: DetectedBillsRepository, private val resourceProvider: ResourceProvider, private val naviNetworkConnectivity: NaviNetworkConnectivity, @@ -69,6 +74,8 @@ constructor( val allDetectedBills = mutableListOf() + private var isCallInterrupted = false + private suspend fun initDetectedBillsLoadingProgress() { while (loadingIndicatorProgress.value < 0.65f) { _loadingIndicatorProgress.update { it + 0.05f } @@ -84,120 +91,285 @@ constructor( _loadingIndicatorProgress.update { 0f } } + private var progressJob: Job? = null + suspend fun startDetectingBills( naviBbpsBaseVM: NaviBbpsBaseVM, - originSessionHandler: BbpsOriginSessionHandler, + coroutineScope: CoroutineScope, + permission: String, + googleToken: String? = null, + email: String? = null, naviBbpsDefaultConfig: NaviBbpsDefaultConfig, updateOpenBottomSheet: suspend (Boolean) -> Unit, updateBottomSheetCancellable: suspend (Boolean) -> Unit, - onShowDetectedBillsBottomSheet: suspend (detectedBills: List) -> Unit, + onShowDetectedBillsBottomSheet: suspend (List) -> Unit, onNoBillsDetectedBottomSheet: suspend () -> Unit, onErrorResponse: suspend () -> Unit, + onNotifyLaterBottomSheet: suspend () -> Unit, ) { - updateBottomSheetCancellable(false) + suspend fun completeLoadingProgress() { + delay(2000) + _loadingIndicatorProgress.update { 1f } + delay(1000) + updateBottomSheetCancellable(true) + } - naviBbpsBaseVM.viewModelScope.launch(Dispatchers.IO) { initDetectedBillsLoadingProgress() } + updateBottomSheetCancellable(false) + resetLoadingIndicatorProgress() + + // cancel any existing job first and start a new job and capture its reference + try { + progressJob?.cancel() + } catch (e: Exception) { + e.log() + } + progressJob = + coroutineScope.launch(coroutineDispatcherProvider.io) { + initDetectedBillsLoadingProgress() + } + + val detectedBillsResponse = + fetchInitialDetectedBills(naviBbpsBaseVM, onErrorResponse) ?: return + + if ( + isConsentAndBillsAlreadyAvailable( + permission = permission, + data = detectedBillsResponse.data!!, + ) + ) { + completeLoadingProgress() + + handleDetectedBillsResponse( + response = detectedBillsResponse, + isPollingSucceededOnce = true, + naviBbpsBaseVM = naviBbpsBaseVM, + updateOpenBottomSheet = updateOpenBottomSheet, + onShowDetectedBillsBottomSheet = onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = onNoBillsDetectedBottomSheet, + onErrorResponse = onErrorResponse, + permission = permission, + onNotifyLaterBottomSheet = onNotifyLaterBottomSheet, + ) + return + } + + if (shouldCallBillDetection(detectedBillsResponse.data!!)) { + val detectionSuccess = + billDetection( + naviBbpsBaseVM = naviBbpsBaseVM, + permission = permission, + googleToken = googleToken, + email = email, + updateOpenBottomSheet = updateOpenBottomSheet, + onErrorResponse = onErrorResponse, + ) + if (!detectionSuccess) return + } + + val (response, isPollingSucceededOnce) = + pollForDetectedBills(naviBbpsBaseVM, naviBbpsDefaultConfig) + + completeLoadingProgress() + + handleDetectedBillsResponse( + response = response, + isPollingSucceededOnce = isPollingSucceededOnce, + naviBbpsBaseVM = naviBbpsBaseVM, + updateOpenBottomSheet = updateOpenBottomSheet, + onShowDetectedBillsBottomSheet = onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = onNoBillsDetectedBottomSheet, + onErrorResponse = onErrorResponse, + permission = permission, + onNotifyLaterBottomSheet = onNotifyLaterBottomSheet, + ) + } + + private suspend fun fetchInitialDetectedBills( + naviBbpsBaseVM: NaviBbpsBaseVM, + onErrorResponse: suspend () -> Unit, + ): RepoResult? { + val response = + detectedBillsRepository.fetchDetectedBills( + metricInfo = + getBbpsMetricInfo( + screenName = naviBbpsBaseVM.naviBbpsVmData.screen.screenName, + isNae = { false }, + ) + ) + + return if (response.isSuccessWithData()) { + response + } else { + onErrorResponse() + null + } + } + + private fun shouldCallBillDetection(data: DetectedBillsResponse): Boolean { + return !data.isSmsConsentProvided || !data.isEmailConsentProvided + } + + private fun isConsentAndBillsAlreadyAvailable( + permission: String, + data: DetectedBillsResponse, + ): Boolean { + return when (permission) { + DetectedBillSource.SMS.name -> { + data.isSmsConsentProvided && data.bills.isNullOrEmpty().not() + } + DetectedBillSource.EMAIL.name -> { + data.isEmailConsentProvided && data.bills.isNullOrEmpty().not() + } + else -> false + } + } + + private suspend fun billDetection( + naviBbpsBaseVM: NaviBbpsBaseVM, + permission: String, + googleToken: String?, + email: String?, + updateOpenBottomSheet: suspend (Boolean) -> Unit, + onErrorResponse: suspend () -> Unit, + ): Boolean { + val response = + retry( + retryCount = DEFAULT_RETRY_COUNT, + retryIntervalInSeconds = RETRY_INTERVAL_IN_SECONDS, + execute = { + detectedBillsRepository.billDetection( + BillDetectionRequest( + permission = permission, + googleToken = googleToken, + emailId = email, + ), + metricInfo = + getBbpsMetricInfo(naviBbpsBaseVM.naviBbpsVmData.screen.screenName), + ) + }, + shouldRetry = { !it.isSuccessWithData() }, + ) + + return if (response.data?.isSuccess().orFalse()) { + true + } else { + updateOpenBottomSheet(false) + onErrorResponse() + false + } + } + + private suspend fun pollForDetectedBills( + naviBbpsBaseVM: NaviBbpsBaseVM, + config: NaviBbpsDefaultConfig, + ): Pair, Boolean> { + var currentPollingCount = 1 var isPollingSucceededOnce = false - val startTime = System.currentTimeMillis() - val timeout = naviBbpsDefaultConfig.detectedBillsPollingTimeoutMillis + val startTime = TrustedTimeAccessor.getCurrentTimeMillis() var response: RepoResult - var requestId: String? = null + do { response = detectedBillsRepository.fetchDetectedBills( - fetchDetectedBillsRequest = - FetchDetectedBillsRequest( - permissions = listOf(DetectedBillSource.SMS.name), - requestId = requestId, - fetchLatestBills = requestId.isNullOrBlank(), - isConsentProvided = requestId.isNullOrBlank(), - ), metricInfo = getBbpsMetricInfo( screenName = naviBbpsBaseVM.naviBbpsVmData.screen.screenName, isNae = { false }, - ), + ) ) - if ( - response.isSuccessWithData() && - response.data?.status == - DetectedBillPollingStatus.DETECTED_BILL_FETCH_STATUS_PENDING.name && - !response.data?.requestId.isNullOrBlank() - ) { - originSessionHandler.setOriginWidget(OriginWidgetStatus.RTUE) + if (response.isSuccessWithData()) { isPollingSucceededOnce = true - requestId = response.data?.requestId - } else { - break + + if (!response.data?.bills.isNullOrEmpty()) { + return response to true + } } - delay(naviBbpsDefaultConfig.detectedBillsPollingWaitTimeMillis) - } while ((System.currentTimeMillis() - startTime) < timeout) + currentPollingCount++ + delay(config.detectedBillsPollingWaitTimeMillis) + } while ( + currentPollingCount <= config.detectedBillsPollingMaxCount && + (TrustedTimeAccessor.getCurrentTimeMillis() - startTime) < + config.detectedBillsPollingTimeoutMillis + ) - _loadingIndicatorProgress.update { 1f } - delay(1000) - updateBottomSheetCancellable(true) + return response to isPollingSucceededOnce + } + suspend fun refreshDetectedBillsAndSelection( + detectedBills: List, + newDetectedBills: List, + ) { + allDetectedBills.refresh(detectedBills) + _selectedDetectedBillIds.update { newDetectedBills.map { it.detectedBillId } } + detectedBillsRepository.saveDetectedBillsToLocalDb(detectedBills) + } + + private suspend fun handleDetectedBillsResponse( + response: RepoResult, + isPollingSucceededOnce: Boolean, + naviBbpsBaseVM: NaviBbpsBaseVM, + updateOpenBottomSheet: suspend (Boolean) -> Unit, + onShowDetectedBillsBottomSheet: suspend (List) -> Unit, + onNoBillsDetectedBottomSheet: suspend () -> Unit, + onErrorResponse: suspend () -> Unit, + permission: String, + onNotifyLaterBottomSheet: suspend () -> Unit, + ) { if (response.isSuccessWithData()) { - val detectedBills = detectedBillsMapper.map(response.data!!) - allDetectedBills.refresh(detectedBills) - _selectedDetectedBillIds.update { detectedBills.map { it.detectedBillId } } - detectedBillsRepository.saveDetectedBillsToLocalDb(detectedBills = detectedBills) - updateOriginWidgetStatus( - detectedBillsSize = detectedBills.size.orZero(), - originSessionHandler = originSessionHandler, + val detectedBills = + detectedBillsMapper.map( + detectedBillsResponse = response.data!!, + existingBills = detectedBillsRepository.getCachedDetectedBills(), + ) + + refreshDetectedBillsAndSelection( + detectedBills = detectedBills, + newDetectedBills = detectedBills, ) if (detectedBills.isNotEmpty()) { onShowDetectedBillsBottomSheet(detectedBills) + } else if (permission == DetectedBillSource.EMAIL.name) { + updateOpenBottomSheet(false) + showNotifyLaterBottomSheet(naviBbpsBaseVM) + onNotifyLaterBottomSheet() } else { onNoBillsDetectedBottomSheet() } } else { onErrorResponse() updateOpenBottomSheet(false) + if (isPollingSucceededOnce) { - updateOriginWidgetStatus( - detectedBillsSize = response.data?.bills?.size.orZero(), - originSessionHandler = originSessionHandler, - ) - // We will notify you later bottom sheet - naviBbpsBaseVM.notifyError( - NaviBbpsErrorConfig( - iconResId = R.drawable.ic_bbps_bell, - title = resourceProvider.getString(R.string.bbps_we_will_notify_you), - description = - resourceProvider.getString( - R.string.bbps_notify_later_bottom_sheet_description - ), - buttonConfigs = - listOf( - NaviBbpsErrorButtonConfig( - text = resourceProvider.getString(R.string.bbps_okay_got_it), - type = NaviBbpsButtonTheme.Primary, - action = NaviBbpsButtonAction.Dismiss, - ) - ), - code = ERROR_CODE_ORIGIN_NOTIFY_LATER, - tag = ERROR_CODE_ORIGIN_NOTIFY_LATER, - ) - ) + showNotifyLaterBottomSheet(naviBbpsBaseVM) + onNotifyLaterBottomSheet() } else { naviBbpsBaseVM.notifyError(response) } } } - private fun updateOriginWidgetStatus( - detectedBillsSize: Int, - originSessionHandler: BbpsOriginSessionHandler, - ) { - if (detectedBillsSize > 0) { - originSessionHandler.setOriginWidget(OriginWidgetStatus.RTUE) - } else { - originSessionHandler.setOriginWidget(OriginWidgetStatus.HIDDEN) - } + private fun showNotifyLaterBottomSheet(naviBbpsBaseVM: NaviBbpsBaseVM) { + naviBbpsBaseVM.notifyError( + NaviBbpsErrorConfig( + iconResId = R.drawable.ic_bbps_bell, + title = resourceProvider.getString(R.string.bbps_we_will_notify_you), + description = + resourceProvider.getString(R.string.bbps_notify_later_bottom_sheet_description), + buttonConfigs = + listOf( + NaviBbpsErrorButtonConfig( + text = resourceProvider.getString(R.string.bbps_okay_got_it), + type = NaviBbpsButtonTheme.Primary, + action = NaviBbpsButtonAction.Dismiss, + ) + ), + code = ERROR_CODE_ORIGIN_NOTIFY_LATER, + tag = ERROR_CODE_ORIGIN_NOTIFY_LATER, + ) + ) } fun onDetectedBillCheckedChanged(detectedBillId: String) { @@ -210,8 +382,7 @@ constructor( suspend fun onAddDetectedBillsClicked( naviBbpsBaseVM: NaviBbpsBaseVM, - initialSource: String, - onBillsAdded: suspend (NewAddedBills?) -> Unit, + onBillsAdded: suspend (NewAddedBills?, Boolean) -> Unit, ) { if (!naviNetworkConnectivity.isInternetConnected()) { naviBbpsBaseVM.notifyError(naviBbpsBaseVM.getNoInternetErrorConfig()) @@ -258,41 +429,71 @@ constructor( val totalBillsRequested = selectedBills.size val totalBillsAddedSuccessfully = response.data?.billDetails?.size ?: 0 - detectedBillsRepository.deleteDetectedBillsFromLocalDb( - detectedBillsIds = response.data?.billDetails?.map { it.detectedBillId }.orEmpty() - ) + + val detectedBillsResponse = + detectedBillsRepository.fetchDetectedBills( + metricInfo = + getBbpsMetricInfo( + screenName = naviBbpsBaseVM.naviBbpsVmData.screen.screenName, + isNae = { false }, + ) + ) + if (detectedBillsResponse.isSuccessWithData()) { + detectedBillsRepository.saveDetectedBillsToLocalDb( + detectedBills = + detectedBillsMapper.map( + detectedBillsResponse = detectedBillsResponse.data!!, + existingBills = detectedBillsRepository.getCachedDetectedBills(), + ) + ) + } else { + detectedBillsRepository.deleteDetectedBillsFromLocalDb( + detectedBillsIds = response.data?.billDetails?.map { it.detectedBillId }.orEmpty() + ) + } + if (totalBillsAddedSuccessfully == 0) { updateFullScreenLoaderState(fullScreenLoaderState = null) - onBillsAdded(null) + onBillsAdded(null, isCallInterrupted) } else { myBillsSyncJob.refreshBills( screenName = naviBbpsBaseVM.naviBbpsVmData.screen.screenName ) - updateFullScreenLoaderState( - fullScreenLoaderState = - FullScreenLoaderState( - isVisible = true, - lottieFileName = ADDING_BILLS_SUCCESS_LOTTIE, - message = - resourceProvider.getString( - R.string.x_added_successfully, - "$totalBillsAddedSuccessfully/$totalBillsRequested", - ), - showLottieInfiniteTimes = false, - ) - ) - delay(2000) + if (!isCallInterrupted) { + updateFullScreenLoaderState( + fullScreenLoaderState = + FullScreenLoaderState( + isVisible = true, + lottieFileName = ADDING_BILLS_SUCCESS_LOTTIE, + message = + resourceProvider.getString( + R.string.x_added_successfully, + "$totalBillsAddedSuccessfully/$totalBillsRequested", + ), + showLottieInfiniteTimes = false, + ) + ) + delay(2000) + } updateFullScreenLoaderState(fullScreenLoaderState = null) onBillsAdded( response.data ?.billDetails ?.map { it.billId } - ?.let { NewAddedBills(newAddedBillIds = it) } + ?.let { NewAddedBills(newAddedBillIds = it) }, + isCallInterrupted, ) + delay(50) } + isCallInterrupted = false } private fun updateFullScreenLoaderState(fullScreenLoaderState: FullScreenLoaderState?) { _fullScreenLoaderState.update { fullScreenLoaderState } } + + fun onFullScreenLoaderDismissed() { + updateFullScreenLoaderState(fullScreenLoaderState = null) + isCallInterrupted = true + } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/NaviBbpsAppDatabase.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/NaviBbpsAppDatabase.kt index 0d947f7cd3..9e9899022d 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/NaviBbpsAppDatabase.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/NaviBbpsAppDatabase.kt @@ -18,6 +18,7 @@ import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_CUSTOM_PREPAID_P import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_DATABASE_BILLERS import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_MOBILE_NUMBER_TO_OPERATOR_CIRCLE_MAPPING_TABLE import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_MOBILE_SERIES_TO_OPERATOR_CIRCLE_MAPPING_TABLE +import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_TABLE_DETECTED_BILLS import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_TABLE_DISMISSED_BILLS import com.navi.bbps.db.NaviBbpsAppDatabase.Companion.NAVI_BBPS_TABLE_MY_SAVED_BILLS import com.navi.bbps.db.converter.PaymentAmountExactnessConverter @@ -48,7 +49,7 @@ import com.navi.bbps.feature.prepaidrecharge.model.view.PrepaidRechargeEntity CustomPrepaidPlansEntity::class, DetectedBillEntity::class, ], - version = 11, + version = 12, exportSchema = false, ) @TypeConverters( @@ -199,7 +200,7 @@ val NAVI_BBPS_DATABASE_MIGRATION_8_9 = override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( """ - CREATE TABLE ${NaviBbpsAppDatabase.NAVI_BBPS_TABLE_DETECTED_BILLS} ( + CREATE TABLE $NAVI_BBPS_TABLE_DETECTED_BILLS ( detectedBillId TEXT PRIMARY KEY NOT NULL, billerId TEXT NOT NULL, billerName TEXT NOT NULL, @@ -247,3 +248,12 @@ val NAVI_BBPS_DATABASE_MIGRATION_10_11 = ) } } + +val NAVI_BBPS_DATABASE_MIGRATION_11_12 = + object : Migration(11, 12) { + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL( + "ALTER TABLE $NAVI_BBPS_TABLE_DETECTED_BILLS ADD COLUMN isBillSeen INTEGER NOT NULL DEFAULT 0" + ) + } + } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/di/NaviBbpsDbModule.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/di/NaviBbpsDbModule.kt index b5210a9cc5..58ffcdc47b 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/di/NaviBbpsDbModule.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/db/di/NaviBbpsDbModule.kt @@ -11,6 +11,7 @@ import android.content.Context import androidx.room.Room import com.navi.bbps.common.BbpsSharedPreferences import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_10_11 +import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_11_12 import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_1_2 import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_2_3 import com.navi.bbps.db.NAVI_BBPS_DATABASE_MIGRATION_3_4 @@ -53,6 +54,7 @@ object NaviBbpsDbModule { NAVI_BBPS_DATABASE_MIGRATION_8_9, NAVI_BBPS_DATABASE_MIGRATION_9_10, NAVI_BBPS_DATABASE_MIGRATION_10_11, + NAVI_BBPS_DATABASE_MIGRATION_11_12, ) .fallbackToDestructiveMigration() .build() diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/entry/NaviBbpsActivity.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/entry/NaviBbpsActivity.kt index 59d13d9acb..bc32a9b67e 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/entry/NaviBbpsActivity.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/entry/NaviBbpsActivity.kt @@ -22,8 +22,10 @@ import com.navi.base.deeplink.DeepLinkManager import com.navi.base.deeplink.util.DeeplinkConstants import com.navi.base.model.CtaData import com.navi.base.utils.BaseUtils +import com.navi.bbps.BuildConfig import com.navi.bbps.common.NaviBbpsAnalytics import com.navi.bbps.common.NaviBbpsAnalytics.Companion.NAVI_BBPS_ACTIVITY +import com.navi.bbps.common.gmail.signin.GmailAccessSignInManager import com.navi.bbps.common.theme.NaviBbpsMaterialTheme import com.navi.bbps.common.utils.BbpsDeeplinkDataConverter import com.navi.bbps.common.utils.ErrorEventHandler @@ -51,6 +53,7 @@ class NaviBbpsActivity : BaseActivity(), BackButtonHandler { @Inject lateinit var errorEventHandler: ErrorEventHandler @Inject lateinit var bbpsDeeplinkDataConverter: Lazy + @Inject lateinit var gmailAccessSignInManager: GmailAccessSignInManager override var isErrorSheetVisible = false override var isErrorSheetCancellable = true override val isNaviControllerInitialized: Boolean @@ -72,6 +75,7 @@ class NaviBbpsActivity : BaseActivity(), BackButtonHandler { initFromIntent(intent) WindowCompat.setDecorFitsSystemWindows(window, true) onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + gmailAccessSignInManager.initialize(BuildConfig.OAUTH_WEB_CLIENT_ID) setContent { NaviBbpsMaterialTheme { CompositionLocalProvider(LocalOverscrollConfiguration provides null) { diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoriesViewModel.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoriesViewModel.kt index c7556cf857..4575ae80d6 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoriesViewModel.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoriesViewModel.kt @@ -109,7 +109,6 @@ import dagger.Lazy import dagger.hilt.android.lifecycle.HiltViewModel import java.util.Collections import javax.inject.Inject -import kotlin.collections.take import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -1016,8 +1015,14 @@ constructor( updateRefreshState(isRefreshing = false, bill = myBillEntity) } - fun updateSnackBarState(show: Boolean, messageId: Int = R.string.bbps_copied_to_clipboard) { - _snackBarState.update { SnackBarState(show = show, messageId = messageId) } + fun updateSnackBarState( + show: Boolean, + messageId: Int = R.string.bbps_copied_to_clipboard, + leadingIconResId: Int = R.drawable.ic_success_green, + ) { + _snackBarState.update { + SnackBarState(show = show, messageId = messageId, leadingIconResId = leadingIconResId) + } } private fun updateArcProtectedStatus() { diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt index 2afaf260bd..67ab649a01 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/BillCategoryViewModelV2.kt @@ -18,7 +18,6 @@ import com.navi.base.utils.ResourceProvider import com.navi.base.utils.orFalse import com.navi.base.utils.orZero import com.navi.bbps.R -import com.navi.bbps.common.AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME import com.navi.bbps.common.ALLOW import com.navi.bbps.common.BbpsSharedPreferences import com.navi.bbps.common.CoinsSyncManager @@ -26,13 +25,14 @@ import com.navi.bbps.common.DENY import com.navi.bbps.common.LocalJsonDataSource import com.navi.bbps.common.NaviBbpsScreen import com.navi.bbps.common.model.NaviBbpsVmData -import com.navi.bbps.common.model.network.BbpsABTestingItemResponse +import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.NaviPermissionResult import com.navi.bbps.common.repository.BbpsCommonRepository import com.navi.bbps.common.session.NaviBbpsSessionHelper import com.navi.bbps.common.usecase.FindLastOrderWithSuccessfulPaymentUseCase -import com.navi.bbps.common.usecase.GetABTestingExperimentUseCase import com.navi.bbps.common.usecase.NaviBbpsConfigUseCase +import com.navi.bbps.common.usecase.OriginExperimentUtils import com.navi.bbps.common.usecase.UploadUserDataUseCase import com.navi.bbps.common.utils.BbpsOriginSessionHandler import com.navi.bbps.common.utils.BillDetailsResponseToEntityMapper @@ -42,6 +42,7 @@ import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo import com.navi.bbps.common.utils.NaviBbpsDateUtils import com.navi.bbps.common.utils.OriginSessionAttributes import com.navi.bbps.common.utils.OriginWidgetStatus +import com.navi.bbps.common.utils.getApiBillsWithSeenStatusApplied import com.navi.bbps.common.viewmodel.OriginBillDetectionHandler import com.navi.bbps.feature.category.model.view.BillCategoryBottomSheetType import com.navi.bbps.feature.category.model.view.BillCategoryStateV2 @@ -64,6 +65,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -84,22 +86,21 @@ constructor( myBillEntityToBillerDetailsEntityMapper: MyBillEntityToBillerDetailsEntityMapper, billDetailsResponseToEntityMapper: BillDetailsResponseToEntityMapper, myBillEntityToBillCategoryEntityMapper: MyBillEntityToBillCategoryEntityMapper, - getABTestingExperimentUseCase: GetABTestingExperimentUseCase, phoneContactManager: Lazy, naviBbpsSessionHelper: NaviBbpsSessionHelper, coinsSyncManager: CoinsSyncManager, naviBbpsConfigUseCase: NaviBbpsConfigUseCase, naviBbpsDateUtils: NaviBbpsDateUtils, - resProvider: ResourceProvider, bbpsCommonRepository: BbpsCommonRepository, naviNetworkConnectivity: NaviNetworkConnectivity, uploadUserDataUseCase: UploadUserDataUseCase, @NaviBbpsGsonBuilder val naviBbpsGson: Gson, private val resourceProvider: ResourceProvider, val originBillDetectionHandler: OriginBillDetectionHandler, - val originSessionHandler: BbpsOriginSessionHandler, - private val detectedBillsRepository: DetectedBillsRepository, findLastOrderWithSuccessfulPaymentUseCase: FindLastOrderWithSuccessfulPaymentUseCase, + private val originSessionHandler: BbpsOriginSessionHandler, + private val detectedBillsRepository: DetectedBillsRepository, + private val originExperimentUtils: OriginExperimentUtils, ) : BillCategoriesViewModel( savedStateHandle = savedStateHandle, @@ -145,8 +146,11 @@ constructor( private val _originSessionAttributes = MutableStateFlow(OriginSessionAttributes.empty()) val originSessionAttributes = _originSessionAttributes.asStateFlow() - private val _onOriginNuxCtaClicked = MutableSharedFlow() - val onOriginNuxCtaClicked = _onOriginNuxCtaClicked.asSharedFlow() + private val _onOriginSmsNuxCtaClicked = MutableSharedFlow() + val onOriginSmsNuxCtaClicked = _onOriginSmsNuxCtaClicked.asSharedFlow() + + private val _onOriginEmailNuxCtaClicked = MutableSharedFlow() + val onOriginEmailNuxCtaClicked = _onOriginEmailNuxCtaClicked.asSharedFlow() private val triggerOriginFromExternalEntry: Boolean = savedStateHandle.get("triggerOriginFromExternalEntry").orFalse() @@ -154,27 +158,34 @@ constructor( private val _startOriginBillDetectionFlow = MutableSharedFlow(replay = 1) val startOriginBillDetectionFlow = _startOriginBillDetectionFlow.asSharedFlow() - internal fun setOnOriginNuxCtaClicked(value: Boolean) { - viewModelScope.launch { _onOriginNuxCtaClicked.emit(value) } + internal fun setOnOriginSmsNuxCtaClicked(value: Boolean) { + viewModelScope.launch(dispatcherProvider.main) { _onOriginSmsNuxCtaClicked.emit(value) } + } + + internal fun setOnOriginEmailNuxCtaClicked(value: Boolean) { + viewModelScope.launch(dispatcherProvider.main) { _onOriginEmailNuxCtaClicked.emit(value) } } fun updatePermissionResult(permissionResult: NaviPermissionResult) { + viewModelScope.safeLaunch { + when (permissionResult) { + is NaviPermissionResult.AllGranted -> { + naviBbpsAnalytics.onSmsPermissionPromptClicked( + smsPermission = ALLOW, + sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), + ) + } - when (permissionResult) { - is NaviPermissionResult.AllGranted -> { - naviBbpsAnalytics.onSmsPermissionPromptClicked( - smsPermission = ALLOW, - sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), - ) - } - else -> { - naviBbpsAnalytics.onSmsPermissionPromptClicked( - smsPermission = DENY, - sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), - ) + else -> { + naviBbpsAnalytics.onSmsPermissionPromptClicked( + smsPermission = DENY, + sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), + ) + updateOpenBottomSheet(isOpen = false) + } } + _permissionResult.update { permissionResult } } - _permissionResult.update { permissionResult } } suspend fun startOriginFlow() { @@ -195,69 +206,119 @@ constructor( init { viewModelScope.launch(dispatcherProvider.io) { launch { billCategoriesStateV2.collect { initBottomSheetHandler() } } + launch { fetchOriginExperiment() } + } + } + + private suspend fun fetchOriginExperiment() { + val isExperimentEnabled = originExperimentUtils.isOriginExperimentEnabled() + + naviBbpsAnalytics.onProjectOriginExperimentFetched( + initialSource = initialSource, + isExperimentEnabled = isExperimentEnabled, + ) + if (isExperimentEnabled) { + onOriginExperimentResultFetched() + } + } + + private fun onOriginExperimentResultFetched() { + viewModelScope.launch(dispatcherProvider.io) { launch { - getABTestingExperimentUseCase.executeAsFlow( - experimentName = AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME, - onValueUpdated = { abTestingItemResponse -> - onOriginExperimentResultFetched(abTestingItemResponse) - }, + detectedBillsRepository + .getCachedDetectedBillsAsFlow() + .distinctUntilChanged() + .collect { + val originSessionAttributes = updateOriginAttributes() + displayOriginSmsNuxIfRequired(originSessionAttributes) + displayOriginEmailNuxIfRequired(originSessionAttributes) + if (triggerOriginFromExternalEntry) { + originSessionHandler.setOriginNuxSeen(DetectedBillSource.SMS) + originSessionHandler.setOriginNuxSeen(DetectedBillSource.EMAIL) + startOriginFlow() + } + } + } + } + } + + private suspend fun displayOriginSmsNuxIfRequired( + originSessionAttributes: OriginSessionAttributes + ) { + if (shouldShowSmsNux(originSessionAttributes)) { + showSmsNuxBottomSheet() + markSmsNuxAsSeen() + } + } + + private fun shouldShowSmsNux(originSessionAttributes: OriginSessionAttributes): Boolean = + !(originSessionAttributes.isOriginSmsNuxSeen || triggerOriginFromExternalEntry) + + private suspend fun showSmsNuxBottomSheet() { + _isBottomSheetCancellable.update { true } + _billCategoryBottomSheetType.update { + BillCategoryBottomSheetType.OriginSmsNux(onAddNowClicked = { emitSmsNuxCtaClicked() }) + } + updateOpenBottomSheet(isOpen = true) + } + + private fun emitSmsNuxCtaClicked() { + viewModelScope.launch(dispatcherProvider.io) { _onOriginSmsNuxCtaClicked.emit(true) } + } + + private fun markSmsNuxAsSeen() { + originSessionHandler.setOriginNuxSeen(detectedBillSource = DetectedBillSource.SMS) + } + + private suspend fun displayOriginEmailNuxIfRequired( + originSessionAttributes: OriginSessionAttributes + ) { + val isOriginEmailSubExperimentEnabled = + originExperimentUtils.isOriginEmailSubExperimentEnabled() + naviBbpsAnalytics.onOriginEmailExperimentEnabled( + initialSource = initialSource, + source = source, + sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), + isOriginEmailSubExperimentEnabled = isOriginEmailSubExperimentEnabled, + ) + + if (!isOriginEmailSubExperimentEnabled) { + return + } + + val isOriginEmailNuxSeen = + originSessionAttributes.isOriginEmailNuxSeen || triggerOriginFromExternalEntry + if ( + !isOriginEmailNuxSeen && + originSessionAttributes.originWidgetStatus == OriginWidgetStatus.EMAIL_FTU + ) { + _isBottomSheetCancellable.update { true } + _billCategoryBottomSheetType.update { + BillCategoryBottomSheetType.OriginEmailNux( + onAddNowClicked = { + viewModelScope.launch(dispatcherProvider.io) { + updateOpenBottomSheet(isOpen = false) + _onOriginEmailNuxCtaClicked.emit(true) + } + } ) } + + updateOpenBottomSheet(isOpen = true) + originSessionHandler.setOriginNuxSeen(detectedBillSource = DetectedBillSource.EMAIL) } } - private fun onOriginExperimentResultFetched(abTestingItemResponse: BbpsABTestingItemResponse) { - viewModelScope.launch(dispatcherProvider.io) { - if (!abTestingItemResponse.isEnabled.orFalse()) { - return@launch - } - - val originSessionAttributes = updateOriginAttributes() - - val isOriginNuxSeen = - originSessionAttributes.isOriginNuxSeen || triggerOriginFromExternalEntry - - naviBbpsAnalytics.onProjectOriginExperimentFetched( - initialSource = initialSource, - isExperimentEnabled = abTestingItemResponse.isEnabled.orFalse(), - isOriginNuxSeen = isOriginNuxSeen, - ) - if (!isOriginNuxSeen) { - _isBottomSheetCancellable.update { true } - _billCategoryBottomSheetType.update { - BillCategoryBottomSheetType.OriginNux( - onAddNowClicked = { - viewModelScope.launch(dispatcherProvider.io) { - _onOriginNuxCtaClicked.emit(true) - } - } - ) - } - - updateOpenBottomSheet(isOpen = true) - originSessionHandler.setOriginNuxSeen() - } - - if (triggerOriginFromExternalEntry) { - originSessionHandler.setOriginNuxSeen() - startOriginFlow() - } - - detectedBillsRepository.getCachedDetectedBillsAsFlow().collect { - updateOriginAttributes() - } + private suspend fun updateOriginAttributes(): OriginSessionAttributes = + withContext(dispatcherProvider.io) { + val updatedAttributes = + originSessionHandler.getOriginAttributes( + naviBbpsVmData.screen, + originExperimentUtils.isOriginEmailSubExperimentEnabled(), + ) + _originSessionAttributes.update { updatedAttributes } + updatedAttributes } - } - - private suspend fun updateOriginAttributes(): OriginSessionAttributes { - val originSessionAttributes = - withContext(dispatcherProvider.io) { - originSessionHandler.getOriginSessionAttributes(naviBbpsVmData.screen) - } - _originSessionAttributes.update { originSessionAttributes } - - return originSessionAttributes - } private fun initBottomSheetHandler() { val screenStructure = @@ -353,18 +414,6 @@ constructor( } } - fun moveToDetectedBillsScreen() { - viewModelScope.launch(dispatcherProvider.io) { - _navigateToNextScreen.emit( - DetectedBillsScreenDestination( - isRootScreen = false, - source = naviBbpsVmData.screen.name, - initialSource = initialSource, - ) - ) - } - } - fun resetBottomSheetCancellable() { _isBottomSheetCancellable.update { true } } @@ -385,11 +434,60 @@ constructor( } } - fun startDetectingBills() { + fun handleOriginRedirectionForRtu(detectedBills: List) { + viewModelScope.safeLaunch(dispatcherProvider.io) { + val filteredList = + detectedBillsRepository.getApiBillsWithSeenStatusApplied( + detectedBillsFromNetwork = detectedBills + ) + + val newDetectedBills = + // filtering only bills whose isBillSeen is false as newly detected bills + filteredList.filter { it.isBillSeen.not() } + + originBillDetectionHandler.refreshDetectedBillsAndSelection( + detectedBills = filteredList, + newDetectedBills = newDetectedBills, + ) + + if (newDetectedBills.isEmpty()) { + _navigateToNextScreen.emit( + DetectedBillsScreenDestination( + isRootScreen = false, + source = naviBbpsVmData.screen.screenName, + initialSource = initialSource, + ) + ) + } else { + detectedBillsRepository.updateAllBillsSeenStatus(isSeen = true) + _billCategoryBottomSheetType.update { + BillCategoryBottomSheetType.DetectedBills( + detectedBills = newDetectedBills, + onBillCheckboxClicked = { detectedBillId -> + originBillDetectionHandler.onDetectedBillCheckedChanged(detectedBillId) + }, + onAddBillsClicked = ::onAddDetectedBillsClicked, + onViewAllBillsClicked = ::onViewAllBillsClicked, + allDetectedBillsCount = originBillDetectionHandler.allDetectedBills.size, + ) + } + updateOpenBottomSheet(isOpen = true) + } + } + } + + fun startDetectingBills( + detectedBillSource: DetectedBillSource = DetectedBillSource.SMS, + googleToken: String? = null, + email: String? = null, + ) { viewModelScope.launch(dispatcherProvider.io) { originBillDetectionHandler.startDetectingBills( - originSessionHandler = originSessionHandler, naviBbpsBaseVM = this@BillCategoriesViewModelV2, + coroutineScope = viewModelScope, + permission = detectedBillSource.name, + googleToken = googleToken, + email = email, naviBbpsDefaultConfig = naviBbpsDefaultConfig, updateOpenBottomSheet = { isOpen -> updateOpenBottomSheet(isOpen) }, updateBottomSheetCancellable = { isCancellable -> @@ -397,42 +495,64 @@ constructor( }, onShowDetectedBillsBottomSheet = { detectedBills -> _originSessionAttributes.update { - originSessionAttributes.value.copy( - originWidgetStatus = OriginWidgetStatus.RTUE, - detectedBills = detectedBills, - isOriginNuxSeen = true, + it.copy( + originWidgetStatus = + when (detectedBillSource) { + DetectedBillSource.SMS -> + if ( + originExperimentUtils + .isOriginEmailSubExperimentEnabled() + ) { + OriginWidgetStatus.SMS_RTU + } else { + OriginWidgetStatus.EMAIL_RTU + } + + DetectedBillSource.EMAIL -> OriginWidgetStatus.EMAIL_RTU + DetectedBillSource.UNKNOWN -> OriginWidgetStatus.HIDDEN + } ) } + naviBbpsAnalytics.onDetectedBillsBottomSheetLanded( initialSource = initialSource, source = naviBbpsVmData.screen.screenName, sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), totalDetectedBills = detectedBills.size, ) - _billCategoryBottomSheetType.update { - BillCategoryBottomSheetType.DetectedBills( - detectedBills = detectedBills, - onBillCheckboxClicked = { detectedBillId -> - originBillDetectionHandler.onDetectedBillCheckedChanged( - detectedBillId - ) - }, - onAddBillsClicked = ::onAddDetectedBillsClicked, - ) - } + handleOriginRedirectionForRtu(detectedBills = detectedBills) }, onNoBillsDetectedBottomSheet = { - _originSessionAttributes.update { - originSessionAttributes.value.copy( - originWidgetStatus = OriginWidgetStatus.HIDDEN, - isOriginNuxSeen = true, - ) - } + updateSessionAttributesInCaseOfNoBills(detectedBillSource) _billCategoryBottomSheetType.update { BillCategoryBottomSheetType.NoBillDetectedError } }, onErrorResponse = { updateOpenBottomSheet(false) }, + onNotifyLaterBottomSheet = { + updateSessionAttributesInCaseOfNoBills(detectedBillSource) + }, + ) + } + } + + private suspend fun updateSessionAttributesInCaseOfNoBills( + detectedBillSource: DetectedBillSource + ) { + _originSessionAttributes.update { + it.copy( + originWidgetStatus = + when (detectedBillSource) { + DetectedBillSource.SMS -> + if (originExperimentUtils.isOriginEmailSubExperimentEnabled()) { + OriginWidgetStatus.EMAIL_FTU + } else { + OriginWidgetStatus.HIDDEN + } + + DetectedBillSource.EMAIL -> OriginWidgetStatus.HIDDEN + DetectedBillSource.UNKNOWN -> return // no update needed + } ) } } @@ -449,8 +569,7 @@ constructor( updateOpenBottomSheet(false) originBillDetectionHandler.onAddDetectedBillsClicked( naviBbpsBaseVM = this@BillCategoriesViewModelV2, - initialSource = initialSource, - onBillsAdded = { newAddedBills -> + onBillsAdded = { newAddedBills, isCallInterrupted -> naviBbpsAnalytics.onFullScreenDetectedBillsAddedScreen( initialSource = initialSource, source = naviBbpsVmData.screen.screenName, @@ -461,16 +580,41 @@ constructor( totalAddedSuccessfully = newAddedBills?.newAddedBillIds?.size.orZero(), ) - _navigateToNextScreen.emit( - MyBillsScreenDestination( - isRootScreen = false, - source = naviBbpsVmData.screen.screenName, - initialSource = initialSource, - newAddedBills = newAddedBills, + if (!isCallInterrupted) { + _navigateToNextScreen.emit( + MyBillsScreenDestination( + isRootScreen = false, + source = naviBbpsVmData.screen.screenName, + initialSource = initialSource, + newAddedBills = newAddedBills, + ) ) - ) + } }, ) } } + + private fun onViewAllBillsClicked() { + viewModelScope.launch(dispatcherProvider.io) { + _navigateToNextScreen.emit( + DetectedBillsScreenDestination( + isRootScreen = false, + source = naviBbpsVmData.screen.screenName, + initialSource = initialSource, + ) + ) + } + } + + fun onBackClickDuringFullScreenLoader() { + viewModelScope.launch(dispatcherProvider.io) { + naviBbpsAnalytics.onFullScreenLoaderBackClicked( + initialSource = initialSource, + source = naviBbpsVmData.screen.screenName, + sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), + ) + originBillDetectionHandler.onFullScreenLoaderDismissed() + } + } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/model/view/BillCategoryBottomSheetType.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/model/view/BillCategoryBottomSheetType.kt index 56eed84431..1ad16499bf 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/model/view/BillCategoryBottomSheetType.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/model/view/BillCategoryBottomSheetType.kt @@ -58,7 +58,9 @@ sealed class BillCategoryBottomSheetType { val localArcTransactionCounterFormatted: String, ) : BillCategoryBottomSheetType() - data class OriginNux(val onAddNowClicked: () -> Unit) : BillCategoryBottomSheetType() + data class OriginSmsNux(val onAddNowClicked: () -> Unit) : BillCategoryBottomSheetType() + + data class OriginEmailNux(val onAddNowClicked: () -> Unit) : BillCategoryBottomSheetType() data class Loading(@DrawableRes val iconResId: Int, val rolodexTitles: List) : BillCategoryBottomSheetType() @@ -67,6 +69,8 @@ sealed class BillCategoryBottomSheetType { val detectedBills: List, val onBillCheckboxClicked: (DetectedBillId) -> Unit, val onAddBillsClicked: () -> Unit, + val onViewAllBillsClicked: () -> Unit, + val allDetectedBillsCount: Int, ) : BillCategoryBottomSheetType() data object NoBillDetectedError : BillCategoryBottomSheetType() diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsEmailOriginNuxBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsEmailOriginNuxBottomSheetContent.kt new file mode 100644 index 0000000000..adfbe7f383 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsEmailOriginNuxBottomSheetContent.kt @@ -0,0 +1,121 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.feature.category.ui + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.navi.bbps.R +import com.navi.bbps.common.theme.NaviBbpsColor +import com.navi.bbps.common.ui.ThemeRoundedButtonWithImage +import com.navi.common.R as commonR +import com.navi.design.font.FontWeightEnum +import com.navi.design.font.getFontWeight +import com.navi.design.font.naviFontFamily +import com.navi.naviwidgets.extensions.NaviText +import com.navi.rr.utils.ext.clickable + +@Composable +fun BbpsEmailOriginNuxBottomSheetContent(onCloseClicked: () -> Unit, onAddNowClicked: () -> Unit) { + Column( + modifier = Modifier.fillMaxWidth().padding(top = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(commonR.drawable.ic_close_black), + contentDescription = null, + modifier = + Modifier.padding(end = 16.dp).size(24.dp).align(Alignment.End).clickable { + onCloseClicked() + }, + ) + + NaviText( + textAlign = TextAlign.Center, + modifier = Modifier.padding(horizontal = 16.dp), + text = stringResource(com.navi.bbps.R.string.bbps_bill_payments), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textTertiary, + lineHeight = 28.sp, + letterSpacing = 1.5.sp, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + NaviText( + textAlign = TextAlign.Center, + text = + buildAnnotatedString { + append(stringResource(com.navi.bbps.R.string.bbps_add_bills_recharges)) + withStyle(style = SpanStyle(color = NaviBbpsColor.ctaPriority)) { + append(stringResource(com.navi.bbps.R.string.bbps_automatically)) + } + }, + fontSize = 20.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textPrimary, + lineHeight = 28.sp, + ) + + Spacer(modifier = Modifier.height(36.dp)) + + Image( + painter = painterResource(com.navi.bbps.R.drawable.ic_bbps_origin_nux), + contentDescription = stringResource(com.navi.bbps.R.string.bbps_add_bills_recharges), + modifier = Modifier, + ) + + Spacer(modifier = Modifier.height(36.dp)) + + NaviText( + modifier = + Modifier.fillMaxWidth() + .padding(horizontal = 16.dp) + .background(color = NaviBbpsColor.lightGreen, shape = RoundedCornerShape(4.dp)) + .padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(com.navi.bbps.R.string.bbps_origin_nux_description_1), + fontSize = 14.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_HEADLINE_REGULAR), + color = NaviBbpsColor.textTertiary, + lineHeight = 22.sp, + textAlign = TextAlign.Center, + ) + + ThemeRoundedButtonWithImage( + modifier = + Modifier.fillMaxWidth() + .background(color = NaviBbpsColor.ctaWhite) + .padding(horizontal = 16.dp, vertical = 32.dp), + text = stringResource(R.string.bbps_sign_in_with_google), + onClick = onAddNowClicked, + imageResId = R.drawable.ic_bbps_google_logo, + imageSize = 16.dp, + ) + } +} diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsOriginNuxBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsSmsOriginNuxBottomSheetContent.kt similarity index 96% rename from android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsOriginNuxBottomSheetContent.kt rename to android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsSmsOriginNuxBottomSheetContent.kt index 23b6656bd5..1a0fe9d2c5 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsOriginNuxBottomSheetContent.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BbpsSmsOriginNuxBottomSheetContent.kt @@ -38,7 +38,7 @@ import com.navi.naviwidgets.extensions.NaviText import com.navi.rr.utils.ext.clickable @Composable -fun BbpsOriginNuxBottomSheetContent(onCloseClicked: () -> Unit, onAddNowClicked: () -> Unit) { +fun BbpsSmsOriginNuxBottomSheetContent(onCloseClicked: () -> Unit, onAddNowClicked: () -> Unit) { Column( modifier = Modifier.fillMaxWidth().padding(top = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, @@ -124,7 +124,7 @@ fun BbpsOriginNuxBottomSheetContent(onCloseClicked: () -> Unit, onAddNowClicked: modifier = Modifier.fillMaxWidth() .background(color = NaviBbpsColor.ctaWhite) - .padding(start = 16.dp, end = 16.dp, top = 32.dp, bottom = 32.dp), + .padding(horizontal = 16.dp, vertical = 32.dp), text = stringResource(R.string.bbps_add_now), onClick = onAddNowClicked, ) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryBottomSheetContent.kt index c6704aef4c..15f4ce9e7a 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryBottomSheetContent.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryBottomSheetContent.kt @@ -160,8 +160,8 @@ fun BillCategoryBottomSheetContent( ) } - is BillCategoryBottomSheetType.OriginNux -> { - BbpsOriginNuxBottomSheetContent( + is BillCategoryBottomSheetType.OriginSmsNux -> { + BbpsSmsOriginNuxBottomSheetContent( onCloseClicked = closeSheet, onAddNowClicked = billCategoryBottomSheetType.onAddNowClicked, ) @@ -182,11 +182,20 @@ fun BillCategoryBottomSheetContent( onDetectedBillCheckboxClicked = billCategoryBottomSheetType.onBillCheckboxClicked, onContinueToAddClicked = billCategoryBottomSheetType.onAddBillsClicked, selectedDetectedBills = selectedDetectedBillIds, + onViewAllBillsClicked = billCategoryBottomSheetType.onViewAllBillsClicked, + allDetectedBillsCount = billCategoryBottomSheetType.allDetectedBillsCount, ) } is BillCategoryBottomSheetType.NoBillDetectedError -> { NoBillDetectedErrorBottomSheetContent(closeSheet = closeSheet) } + + is BillCategoryBottomSheetType.OriginEmailNux -> { + BbpsEmailOriginNuxBottomSheetContent( + onCloseClicked = closeSheet, + onAddNowClicked = billCategoryBottomSheetType.onAddNowClicked, + ) + } } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryScreenV2.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryScreenV2.kt index 5e8d532241..160ed951b1 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryScreenV2.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/BillCategoryScreenV2.kt @@ -7,6 +7,7 @@ package com.navi.bbps.feature.category.ui +import android.widget.Toast import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility @@ -62,6 +63,7 @@ import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -112,6 +114,8 @@ import com.navi.bbps.common.POPULAR_CATEGORIES import com.navi.bbps.common.SCROLL_OFFSET_FOR_TITLE_IN_HEADER import com.navi.bbps.common.UNPAID_BILL_COUNT_DELAY import com.navi.bbps.common.UNPAID_BILL_COUNT_DURATION +import com.navi.bbps.common.gmail.model.GmailAccessState +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.NaviPermissionResult import com.navi.bbps.common.model.view.RefreshBillState import com.navi.bbps.common.model.view.rememberMultiplePermissions @@ -220,7 +224,6 @@ fun BillCategoriesScreenV2( viewModel.originBillDetectionHandler.fullScreenLoaderState.collectAsStateWithLifecycle() val isBottomSheetCancellable by viewModel.isBottomSheetCancellable.collectAsStateWithLifecycle() val originSessionAttributes by viewModel.originSessionAttributes.collectAsStateWithLifecycle() - val unpaidBills by viewModel.unpaidBills.collectAsStateWithLifecycle() var navigateBackWithResult by remember { mutableStateOf(null) } @@ -280,16 +283,24 @@ fun BillCategoriesScreenV2( BackHandler { when { fullScreenLoaderState?.isVisible.orFalse() -> { - // no-op + viewModel.onBackClickDuringFullScreenLoader() + viewModel.updateSnackBarState( + show = true, + messageId = R.string.bbps_we_will_once_we_find_bills, + leadingIconResId = WidgetsR.drawable.ic_info_gray, + ) } + isBottomSheetVisible.orFalse() -> { viewModel.dismissBottomSheet() return@BackHandler } + modalBottomSheetState.isVisible -> { closeSheet() return@BackHandler } + billCategoryStateV2 is BillCategoryStateV2.Loaded -> { val screenState = billCategoryStateV2 as BillCategoryStateV2.Loaded screenState.billCategories.screenStructure?.systemBackCta?.let { @@ -297,6 +308,7 @@ fun BillCategoriesScreenV2( } return@BackHandler } + else -> { naviBbpsActivity.finish() } @@ -434,12 +446,14 @@ fun BillCategoriesScreenV2( NAVI_BBPS_NAVIGATION_CUSTOM -> { handleCategoriesClicked(ctaAction) } + NAVI_BBPS_NAVIGATION_DISMISS_BOTTOM_SHEET -> { scope.launch { uiTronBottomSheetState.hide() viewModel.dismissBottomSheet() } } + else -> Unit } } @@ -453,6 +467,7 @@ fun BillCategoriesScreenV2( ) BillCategoriesShimmer() } + is BillCategoryStateV2.Loaded -> { if (fullScreenLoaderState?.isVisible.orFalse()) { fullScreenLoaderState?.let { @@ -532,6 +547,7 @@ fun BillCategoriesScreenV2( ) } } + is BillCategoryStateV2.Error -> {} } } @@ -582,7 +598,6 @@ fun BillCategoryScreenRenderer( closeSheet: () -> Unit, navigator: DestinationsNavigator, ) { - if (screenStructure == null) return val scrollState = rememberScrollState() val offset by remember { derivedStateOf { scrollState.value } } @@ -638,12 +653,6 @@ fun BillCategoryScreenRenderer( } } - LaunchedEffect(Unit) { - viewModel.originSessionHandler.getOriginSessionAttributes( - screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES_V2 - ) - } - val fetchSmsPermissionState = rememberMultiplePermissions( permissions = @@ -658,6 +667,7 @@ fun BillCategoryScreenRenderer( openSheet() viewModel.uploadUserDataUseCase.execute { viewModel.startDetectingBills() } } + NaviPermissionResult.HardDenied -> { viewModel.updatePermissionResult(permissionResult = it) navigator.navigate( @@ -668,9 +678,11 @@ fun BillCategoryScreenRenderer( ) ) } + NaviPermissionResult.ShowRationale -> { viewModel.updatePermissionResult(permissionResult = it) } + NaviPermissionResult.None -> {} } } @@ -690,8 +702,44 @@ fun BillCategoryScreenRenderer( Unit } + val launcher = + naviBbpsActivity.gmailAccessSignInManager.createSignInResultLauncher { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.UserCancelled -> { + naviBbpsAnalytics.onGmailAccessSignInCancelled( + source = source, + sessionAttribute = viewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AccessGranted -> { + viewModel.showLoadingBottomSheet() + openSheet() + viewModel.startDetectingBills( + detectedBillSource = DetectedBillSource.EMAIL, + googleToken = gmailAccessState.signInResponse.authCode, + email = gmailAccessState.signInResponse.emailId, + ) + } + + is GmailAccessState.UnexpectedSignInResult -> { + naviBbpsAnalytics.onGmailAccessSignInFailed( + source = source, + sessionAttribute = viewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AuthorizationException -> {} + + else -> {} + } + } + + val currentOriginSessionAttributes by rememberUpdatedState(originSessionAttributes) val checkPermissionAndProceedWithBillDetection = { - if (originSessionAttributes.originWidgetStatus == OriginWidgetStatus.FTUE) { + if (currentOriginSessionAttributes.originWidgetStatus == OriginWidgetStatus.SMS_FTU) { if (fetchSmsPermissionState.allPermissionsGranted) { viewModel.showLoadingBottomSheet() openSheet() @@ -699,8 +747,73 @@ fun BillCategoryScreenRenderer( } else { requestPermission() } - } else { - viewModel.moveToDetectedBillsScreen() + } else if ( + currentOriginSessionAttributes.originWidgetStatus == OriginWidgetStatus.SMS_RTU || + currentOriginSessionAttributes.originWidgetStatus == OriginWidgetStatus.EMAIL_RTU + ) { + viewModel.handleOriginRedirectionForRtu( + detectedBills = currentOriginSessionAttributes.detectedBills + ) + } else if ( + currentOriginSessionAttributes.originWidgetStatus == OriginWidgetStatus.EMAIL_FTU + ) { + naviBbpsActivity.gmailAccessSignInManager.signIn( + launcher = launcher, + callback = { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.NotInitialized -> { + Toast.makeText( + naviBbpsActivity, + R.string.bbps_email_verification_failed, + Toast.LENGTH_SHORT, + ) + .show() + + naviBbpsAnalytics.onGmailAccessSignInFailed( + source = source, + sessionAttribute = viewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.ServerCredentialsMissing -> { + Toast.makeText( + naviBbpsActivity, + R.string.bbps_email_verification_failed, + Toast.LENGTH_SHORT, + ) + .show() + + naviBbpsAnalytics.onGmailAccessSignInFailed( + source = source, + sessionAttribute = viewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + else -> { + // no-op + } + } + }, + ) + } + } + + LaunchedEffect(Unit) { + viewModel.onOriginSmsNuxCtaClicked.collectLatest { isCtaClicked -> + if (isCtaClicked) { + naviBbpsAnalytics.onOriginNuxACtaClicked( + source = source, + sessionAttribute = viewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + smsPermissionState = fetchSmsPermissionState.allPermissionsGranted, + originSessionAttributes = originSessionAttributes, + nuxType = "SMS", + ) + checkPermissionAndProceedWithBillDetection() + viewModel.setOnOriginSmsNuxCtaClicked(false) + } } } @@ -720,7 +833,7 @@ fun BillCategoryScreenRenderer( } LaunchedEffect(Unit) { - viewModel.onOriginNuxCtaClicked.collectLatest { isCtaClicked -> + viewModel.onOriginEmailNuxCtaClicked.collectLatest { isCtaClicked -> if (isCtaClicked) { naviBbpsAnalytics.onOriginNuxACtaClicked( source = source, @@ -728,9 +841,10 @@ fun BillCategoryScreenRenderer( initialSource = initialSource, smsPermissionState = fetchSmsPermissionState.allPermissionsGranted, originSessionAttributes = originSessionAttributes, + nuxType = "EMAIL", ) checkPermissionAndProceedWithBillDetection() - viewModel.setOnOriginNuxCtaClicked(false) + viewModel.setOnOriginEmailNuxCtaClicked(false) } } } @@ -863,6 +977,7 @@ fun BillCategoryScreenRenderer( SnackBarPredefinedConfig.successConfig( title = stringResource(id = snackBarState.messageId), trailingIconResId = null, + leadingIconResId = snackBarState.leadingIconResId, ), onDismissed = { viewModel.updateSnackBarState(show = false) }, ) @@ -914,6 +1029,7 @@ private fun BbpsLandingPageWidgetRenderer( modifier = modifier, ) } + BillCategoryWidgets.MY_BILLS_WIDGET.name -> { MyBillsWidget( modifier = modifier, @@ -930,6 +1046,7 @@ private fun BbpsLandingPageWidgetRenderer( refreshBillItemAndStatus = refreshBillItemAndStatus, ) } + BillCategoryWidgets.ARC_PROTECT_WIDGET.name -> { if (isArcProtected) { LaunchedEffect(Unit) { @@ -941,18 +1058,19 @@ private fun BbpsLandingPageWidgetRenderer( Spacer(modifier = Modifier.height(32.dp)) } } + BillCategoryWidgets.ORIGIN_WIDGET.name -> { if (originSessionAttributes.originWidgetStatus != OriginWidgetStatus.HIDDEN) { OriginLandingWidget( modifier = modifier, onAddClicked = onAddDetectedBillsClicked, - isFirstTimeUser = - originSessionAttributes.originWidgetStatus == OriginWidgetStatus.FTUE, + originWidgetStatus = originSessionAttributes.originWidgetStatus, detectedBills = originSessionAttributes.detectedBills, ) Spacer(modifier = Modifier.height(32.dp)) } } + BillCategoryWidgets.OFFERS_ROLODEX_WIDGET.name -> { LaunchedEffect(Unit) { naviBbpsAnalytics?.rewardCampaignList( @@ -1241,9 +1359,11 @@ private fun BbpsUiTronBottomSheet( (structure.bottomSheetPercentageHeight ?: 0.5f), ) } + AlchemistBottomSheetStructure.UiStrategy.DEFAULT.name -> { Modifier.height(IntrinsicSize.Max) } + else -> { Modifier.height(IntrinsicSize.Max) } @@ -1426,6 +1546,7 @@ internal fun PendingBillsSection( onClicked = onShowMoreLessButtonClicked, ) } + is PendingBillsShowMoreLessButtonState.ShowMore -> { Spacer(modifier = Modifier.height(16.dp)) ShowMoreLessButton( @@ -1434,6 +1555,7 @@ internal fun PendingBillsSection( onClicked = onShowMoreLessButtonClicked, ) } + is PendingBillsShowMoreLessButtonState.Hidden -> Unit } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/DetectedBillsBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/DetectedBillsBottomSheetContent.kt index afff89a866..dcd8d670ec 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/DetectedBillsBottomSheetContent.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/category/ui/DetectedBillsBottomSheetContent.kt @@ -9,6 +9,7 @@ package com.navi.bbps.feature.category.ui import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -42,8 +43,10 @@ import com.navi.bbps.R import com.navi.bbps.common.BULLET import com.navi.bbps.common.NaviBbpsDimens import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.theme.NaviBbpsColor -import com.navi.bbps.common.ui.ImageWithCircularBackground +import com.navi.bbps.common.ui.BbpsCircleImage +import com.navi.bbps.common.ui.SecondaryRoundedButton import com.navi.bbps.common.ui.ThemeRoundedButton import com.navi.common.R as CommonR import com.navi.design.font.FontWeightEnum @@ -59,8 +62,10 @@ fun DetectedBillsBottomSheetContent( detectedBills: List, selectedDetectedBills: List, onContinueToAddClicked: () -> Unit, + onViewAllBillsClicked: () -> Unit, onDetectedBillCheckboxClicked: (DetectedBillId) -> Unit, closeSheet: () -> Unit, + allDetectedBillsCount: Int, ) { val scrollState = rememberScrollState() Box( @@ -97,7 +102,7 @@ fun DetectedBillsBottomSheetContent( NaviText( modifier = Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), - text = stringResource(R.string.bbps_bills_and_recharges_found), + text = stringResource(R.string.bbps_x_new_bills_found, detectedBills.size), color = NaviBbpsColor.textPrimary, fontFamily = naviFontFamily, fontSize = 16.sp, @@ -111,7 +116,7 @@ fun DetectedBillsBottomSheetContent( NaviText( modifier = Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), - text = stringResource(R.string.bbps_found_following_from_sms), + text = stringResource(R.string.bbps_new_bills_found_description), color = NaviBbpsColor.textTertiary, fontFamily = naviFontFamily, fontSize = 14.sp, @@ -165,11 +170,27 @@ fun DetectedBillsBottomSheetContent( ThemeRoundedButton( modifier = Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), - text = stringResource(id = R.string.bbps_continue_to_add), + text = stringResource(id = R.string.bbps_add_to_my_bills), enabled = selectedDetectedBills.isNotEmpty(), ) { onContinueToAddClicked() } + + Spacer(modifier = Modifier.height(16.dp)) + + SecondaryRoundedButton( + modifier = + Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), + text = + stringResource( + id = R.string.bbps_view_all_bills_with_count, + allDetectedBillsCount, + ), + onClick = { + onViewAllBillsClicked() + closeSheet() + }, + ) } } } @@ -190,9 +211,9 @@ private fun DetectedBillItem( ) .padding(horizontal = 16.dp, vertical = 12.dp) .clickableDebounce { onCheckboxClicked() }, - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { - ImageWithCircularBackground( + BbpsCircleImage( imageUrl = detectedBillEntity.billerLogo, placeholderIconResId = CommonR.drawable.navi_common_ic_biller_placeholder, boxSize = 40.dp, @@ -201,7 +222,7 @@ private fun DetectedBillItem( Spacer(modifier = Modifier.width(8.dp)) - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.Center) { Row { NaviText( text = detectedBillEntity.categoryTitle, @@ -248,15 +269,50 @@ private fun DetectedBillItem( overflow = TextOverflow.Ellipsis, lineHeight = 20.sp, ) + Spacer(modifier = Modifier.height(4.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = + if (detectedBillEntity.detectedBillSource == DetectedBillSource.SMS) { + painterResource(id = R.drawable.ic_bbps_sms_icon) + } else { + painterResource(id = R.drawable.ic_bbps_email_icon) + }, + contentDescription = "", + modifier = + Modifier.padding(top = 2.dp).size(12.dp).align(Alignment.CenterVertically), + ) + Spacer(modifier = Modifier.width(4.dp)) + NaviText( + text = + if (detectedBillEntity.detectedBillSource == DetectedBillSource.SMS) { + stringResource(id = R.string.bbps_bills_found_via_sms) + } else { + stringResource(id = R.string.bbps_bills_found_via_email) + }, + color = NaviBbpsColor.textTertiary, + fontFamily = naviFontFamily, + fontSize = 10.sp, + maxLines = 1, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + overflow = TextOverflow.Ellipsis, + lineHeight = 20.sp, + ) + } } Spacer(modifier = Modifier.width(24.dp)) - TriStateCheckbox( - modifier = Modifier.size(16.dp).clip(shape = RoundedCornerShape(2.dp)), - state = if (isSelected) ToggleableState.On else ToggleableState.Off, - onClick = onCheckboxClicked, - enabled = true, - colors = CheckboxDefaults.colors(checkedColor = NaviBbpsColor.ctaPrimary), - ) + Column(modifier = Modifier.align(alignment = Alignment.CenterVertically)) { + TriStateCheckbox( + modifier = Modifier.size(16.dp).clip(shape = RoundedCornerShape(2.dp)), + state = if (isSelected) ToggleableState.On else ToggleableState.Off, + onClick = onCheckboxClicked, + enabled = true, + colors = CheckboxDefaults.colors(checkedColor = NaviBbpsColor.ctaPrimary), + ) + } } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/customerinput/CustomerDataInputViewModel.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/customerinput/CustomerDataInputViewModel.kt index 94645f6eac..4bb2347dda 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/customerinput/CustomerDataInputViewModel.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/customerinput/CustomerDataInputViewModel.kt @@ -25,8 +25,6 @@ import com.navi.bbps.common.NaviBbpsScreen import com.navi.bbps.common.mapper.DetectedBillsMapper import com.navi.bbps.common.model.NaviBbpsVmData import com.navi.bbps.common.model.config.NaviBbpsDefaultConfig -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest -import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.repository.BbpsCommonRepository import com.navi.bbps.common.session.NaviBbpsSessionHelper import com.navi.bbps.common.usecase.NaviBbpsConfigUseCase @@ -682,23 +680,20 @@ constructor( private suspend fun refreshOriginDetectedBills() { val response = detectedBillsRepository.fetchDetectedBills( - fetchDetectedBillsRequest = - FetchDetectedBillsRequest( - permissions = listOf(DetectedBillSource.SMS.name), - requestId = null, - fetchLatestBills = false, - isConsentProvided = false, - ), metricInfo = getBbpsMetricInfo( screenName = naviBbpsVmData.screen.screenName, isNae = { false }, - ), + ) ) if (response.isSuccessWithData()) { response.data?.let { detectedBillsResponse -> - val detectedBills = detectedBillsMapper.map(detectedBillsResponse) + val detectedBills = + detectedBillsMapper.map( + detectedBillsResponse = detectedBillsResponse, + existingBills = detectedBillsRepository.getCachedDetectedBills(), + ) detectedBillsRepository.saveDetectedBillsToLocalDb(detectedBills) } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsRepository.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsRepository.kt index d1a2ed294b..cbc71aeeb7 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsRepository.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsRepository.kt @@ -7,11 +7,13 @@ package com.navi.bbps.feature.detectedbills +import com.navi.bbps.common.model.network.BillDetectionRequest +import com.navi.bbps.common.model.network.BillDetectionResponse import com.navi.bbps.common.model.network.DetectedBillsResponse -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsResponse import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.utils.getApiBillsWithSeenStatusApplied import com.navi.bbps.feature.detectedbills.db.DetectedBillDao import com.navi.bbps.feature.detectedbills.model.network.DeleteDetectedBillResponse import com.navi.bbps.network.service.NaviBbpsRetrofitService @@ -28,14 +30,25 @@ constructor( private val detectedBillDao: DetectedBillDao, ) : ResponseCallback() { suspend fun fetchDetectedBills( - fetchDetectedBillsRequest: FetchDetectedBillsRequest, - metricInfo: MetricInfo>, + metricInfo: MetricInfo> ): RepoResult { + val response = + apiResponseCallback( + response = naviBbpsRetrofitService.fetchDetectedBills(), + metricInfo = metricInfo, + ) + return response + } + + suspend fun billDetection( + billDetectionRequest: BillDetectionRequest, + metricInfo: MetricInfo>, + ): RepoResult { val response = apiResponseCallback( response = - naviBbpsRetrofitService.fetchDetectedBills( - fetchDetectedBillsRequest = fetchDetectedBillsRequest + naviBbpsRetrofitService.billDetection( + billDetectionRequest = billDetectionRequest ), metricInfo = metricInfo, ) @@ -58,9 +71,10 @@ constructor( } suspend fun saveDetectedBillsToLocalDb(detectedBills: List) { - detectedBillDao.refresh( - detectedBills = detectedBills.filter { it.detectedBillId.isNotBlank() } - ) + val filteredList = + getApiBillsWithSeenStatusApplied(detectedBillsFromNetwork = detectedBills) + + detectedBillDao.refresh(filteredList) } fun getCachedDetectedBillsAsFlow(): Flow> { @@ -87,4 +101,8 @@ constructor( suspend fun getCachedDetectedBills(): List { return detectedBillDao.getAllDetectedBills() } + + suspend fun updateAllBillsSeenStatus(isSeen: Boolean) { + detectedBillDao.updateAllBillsSeenStatus(isSeen = isSeen) + } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsViewModel.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsViewModel.kt index c44ce07c59..b63ef8feed 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsViewModel.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/DetectedBillsViewModel.kt @@ -18,24 +18,34 @@ import com.navi.bbps.R import com.navi.bbps.common.ADDING_BILLS_SUCCESS_LOTTIE import com.navi.bbps.common.ADDING_DETECTED_BILLS_LOTTIE import com.navi.bbps.common.DEFAULT_RETRY_COUNT +import com.navi.bbps.common.ERROR_CODE_ORIGIN_NOTIFY_LATER import com.navi.bbps.common.NaviBbpsAnalytics import com.navi.bbps.common.NaviBbpsScreen import com.navi.bbps.common.RETRY_INTERVAL_IN_SECONDS import com.navi.bbps.common.mapper.DetectedBillsMapper import com.navi.bbps.common.model.NaviBbpsVmData +import com.navi.bbps.common.model.config.NaviBbpsDefaultConfig import com.navi.bbps.common.model.network.BillerInfo import com.navi.bbps.common.model.network.DetectedBillsResponse -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsResponse import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.DetectedBillStatus import com.navi.bbps.common.model.view.FullScreenLoaderState +import com.navi.bbps.common.model.view.NaviBbpsButtonAction +import com.navi.bbps.common.model.view.NaviBbpsButtonTheme +import com.navi.bbps.common.model.view.NaviBbpsErrorButtonConfig +import com.navi.bbps.common.model.view.NaviBbpsErrorConfig import com.navi.bbps.common.session.NaviBbpsSessionHelper +import com.navi.bbps.common.usecase.OriginExperimentUtils +import com.navi.bbps.common.utils.BbpsOriginSessionHandler import com.navi.bbps.common.utils.NaviBbpsCommonUtils import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo +import com.navi.bbps.common.utils.OriginSessionAttributes +import com.navi.bbps.common.utils.OriginWidgetStatus import com.navi.bbps.common.viewmodel.NaviBbpsBaseVM +import com.navi.bbps.common.viewmodel.OriginBillDetectionHandler import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity import com.navi.bbps.feature.category.model.view.BillCategoryEntity import com.navi.bbps.feature.customerinput.model.network.DeviceDetails @@ -43,6 +53,7 @@ import com.navi.bbps.feature.customerinput.model.view.CustomerParamsExtraData import com.navi.bbps.feature.destinations.CustomerDataInputScreenDestination import com.navi.bbps.feature.destinations.MyBillsScreenDestination import com.navi.bbps.feature.detectedbills.model.view.AddDetectedBillProgressState +import com.navi.bbps.feature.detectedbills.model.view.DeleteDetectedBillShimmerState import com.navi.bbps.feature.detectedbills.model.view.DetectedBillCustomerInputResult import com.navi.bbps.feature.detectedbills.model.view.DetectedBillsBottomSheetType import com.navi.bbps.feature.detectedbills.model.view.NewAddedBills @@ -61,11 +72,14 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @HiltViewModel class DetectedBillsViewModel @@ -78,6 +92,9 @@ constructor( private val myBillsSyncJob: MyBillsSyncJob, private val resourceProvider: ResourceProvider, private val naviBbpsSessionHelper: NaviBbpsSessionHelper, + private val originSessionHandler: BbpsOriginSessionHandler, + private val originExperimentUtils: OriginExperimentUtils, + val originBillDetectionHandler: OriginBillDetectionHandler, ) : NaviBbpsBaseVM( naviBbpsVmData = NaviBbpsVmData(screen = NaviBbpsScreen.NAVI_BBPS_DETECTED_BILLS_SCREEN) @@ -104,8 +121,17 @@ constructor( private val _fullScreenLoaderState = MutableStateFlow(null) val fullScreenLoaderState = _fullScreenLoaderState.asStateFlow() + private val _deleteDetectedBillShimmerState = + MutableStateFlow(DeleteDetectedBillShimmerState(deleteBillInProgress = false)) + val deleteDetectedBillShimmerState = _deleteDetectedBillShimmerState.asStateFlow() + + private val _originSessionAttributes = MutableStateFlow(OriginSessionAttributes.empty()) + val originSessionAttributes = _originSessionAttributes.asStateFlow() + private val detectedBillsMapper by lazy { DetectedBillsMapper() } + private var naviBbpsDefaultConfig = NaviBbpsDefaultConfig() + private val _detectedBillsBottomSheetType = MutableStateFlow( DetectedBillsBottomSheetType.MenuOptions( @@ -134,6 +160,22 @@ constructor( ) val isAddDetectedBillInProgress = _isAddDetectedBillInProgress.asStateFlow() + private val _openBottomSheet = MutableStateFlow(null) + val openBottomSheet = _openBottomSheet.asStateFlow() + + val isOriginEmailWidgetVisible = + originSessionAttributes + .map { + it.originWidgetStatus == OriginWidgetStatus.SMS_RTU && + originExperimentUtils.isOriginEmailSubExperimentEnabled() + } + .flowOn(dispatcherProvider.io) + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(), + initialValue = false, + ) + val isUserActionBlocked = combine( _isDetectedBillsInProgress.asStateFlow(), @@ -152,31 +194,157 @@ constructor( val initialSource = savedStateHandle.get("initialSource").orEmpty() + init { + viewModelScope.launch(dispatcherProvider.io) { launch { fetchOriginExperiment() } } + } + + private fun updateDeleteDetectedBillShimmerState( + deleteBillInProgress: Boolean, + detectedBillItem: DetectedBillEntity?, + ) { + _deleteDetectedBillShimmerState.update { + DeleteDetectedBillShimmerState( + deleteBillInProgress = deleteBillInProgress, + detectedBillItem = detectedBillItem, + ) + } + } + + private suspend fun fetchOriginExperiment() { + val isExperimentEnabled = originExperimentUtils.isOriginExperimentEnabled() + + if (isExperimentEnabled) { + onOriginExperimentResultFetched() + } + } + + private fun onOriginExperimentResultFetched() { + viewModelScope.launch(dispatcherProvider.io) { + launch { + detectedBillsRepository + .getCachedDetectedBillsAsFlow() + .distinctUntilChanged() + .collect { updateOriginAttributes() } + } + } + } + + private suspend fun updateOriginAttributes(): OriginSessionAttributes = + withContext(dispatcherProvider.io) { + val updatedAttributes = + originSessionHandler.getOriginAttributes( + screen = naviBbpsVmData.screen, + isOriginEmailSubExperimentEnabled = + originExperimentUtils.isOriginEmailSubExperimentEnabled(), + ) + _originSessionAttributes.update { updatedAttributes } + updatedAttributes + } + + private suspend fun displayNotifyLaterBottomSheet() { + updateOpenBottomSheet(false) + notifyError( + NaviBbpsErrorConfig( + iconResId = R.drawable.ic_bbps_bell, + title = resourceProvider.getString(R.string.bbps_we_will_notify_you), + description = + resourceProvider.getString(R.string.bbps_notify_later_bottom_sheet_description), + buttonConfigs = + listOf( + NaviBbpsErrorButtonConfig( + text = resourceProvider.getString(R.string.bbps_okay_got_it), + type = NaviBbpsButtonTheme.Primary, + action = NaviBbpsButtonAction.Dismiss, + ) + ), + code = ERROR_CODE_ORIGIN_NOTIFY_LATER, + tag = ERROR_CODE_ORIGIN_NOTIFY_LATER, + ) + ) + } + + fun startDetectingBills( + detectedBillSource: DetectedBillSource = DetectedBillSource.SMS, + googleToken: String? = null, + email: String? = null, + ) { + viewModelScope.launch(dispatcherProvider.io) { + originBillDetectionHandler.startDetectingBills( + naviBbpsBaseVM = this@DetectedBillsViewModel, + coroutineScope = viewModelScope, + permission = detectedBillSource.name, + googleToken = googleToken, + email = email, + naviBbpsDefaultConfig = naviBbpsDefaultConfig, + updateOpenBottomSheet = { isOpen -> updateOpenBottomSheet(isOpen) }, + updateBottomSheetCancellable = {}, + onShowDetectedBillsBottomSheet = { detectedBills -> + if (detectedBillSource == DetectedBillSource.EMAIL) { + _originSessionAttributes.update { + originSessionAttributes.value.copy( + originWidgetStatus = OriginWidgetStatus.EMAIL_RTU, + detectedBills = detectedBills, + isOriginEmailNuxSeen = true, + ) + } + } + displayNotifyLaterBottomSheet() + }, + onNoBillsDetectedBottomSheet = { displayNotifyLaterBottomSheet() }, + onErrorResponse = { updateOpenBottomSheet(false) }, + onNotifyLaterBottomSheet = { updateSessionAttributesInCaseOfNoBills() }, + ) + } + } + + private fun updateSessionAttributesInCaseOfNoBills() { + val newStatus = + if (originSessionAttributes.value.detectedBills.isNotEmpty()) { + OriginWidgetStatus.EMAIL_RTU + } else { + OriginWidgetStatus.HIDDEN + } + _originSessionAttributes.update { it.copy(originWidgetStatus = newStatus) } + } + fun fetchDetectedBills() { viewModelScope.launch(dispatcherProvider.io) { val response = detectedBillsRepository.fetchDetectedBills( - fetchDetectedBillsRequest = - FetchDetectedBillsRequest( - permissions = listOf(DetectedBillSource.SMS.name), - requestId = null, - fetchLatestBills = false, - isConsentProvided = false, - ), metricInfo = getBbpsMetricInfo( screenName = naviBbpsVmData.screen.screenName, isNae = { false }, - ), + ) ) if (response.isSuccessWithData()) { - val detectedBills = detectedBillsMapper.map(response.data as DetectedBillsResponse) + val detectedBills = + detectedBillsMapper.map( + detectedBillsResponse = response.data as DetectedBillsResponse, + existingBills = detectedBillsRepository.getCachedDetectedBills(), + ) detectedBillsRepository.saveDetectedBillsToLocalDb(detectedBills) } } } + fun showLoadingBottomSheet() { + originBillDetectionHandler.resetLoadingIndicatorProgress() + _detectedBillsBottomSheetType.update { + DetectedBillsBottomSheetType.Loading( + iconResId = R.drawable.ic_bbps_bill_loading, + rolodexTitles = + listOf( + resourceProvider.getString(R.string.bbps_finding_bills_and_recharges), + resourceProvider.getString( + R.string.bbps_please_wait_taking_longer_than_usual + ), + ), + ) + } + } + fun getNaviBbpsSessionAttributes(): Map = naviBbpsSessionHelper.getNaviBbpsSessionAttributes() @@ -203,6 +371,10 @@ constructor( detectedBillEntity = detectedBillEntity, sessionAttribute = getNaviBbpsSessionAttributes(), ) + updateDeleteDetectedBillShimmerState( + deleteBillInProgress = true, + detectedBillItem = detectedBillEntity, + ) viewModelScope.safeLaunch(dispatcherProvider.io) { delay(50) // before this bottom sheet is getting closed, added for smoother @@ -247,6 +419,11 @@ constructor( } else { updateSnackBarState(show = true, messageId = R.string.bbps_bill_deletion_failed) } + + updateDeleteDetectedBillShimmerState( + deleteBillInProgress = false, + detectedBillItem = null, + ) } } @@ -527,6 +704,12 @@ constructor( _fullScreenLoaderState.update { fullScreenLoaderState } } + private suspend fun updateOpenBottomSheet(isOpen: Boolean) { + _openBottomSheet.update { isOpen } + delay(50) + _openBottomSheet.update { null } + } + data class SnackBarState( val show: Boolean, val messageId: Int = R.string.bbps_account_removed_successfully, diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/db/DetectedBillDao.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/db/DetectedBillDao.kt index 90c2586733..d23618ce4a 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/db/DetectedBillDao.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/db/DetectedBillDao.kt @@ -37,4 +37,7 @@ interface DetectedBillDao { deleteAll() insertAll(detectedBills = detectedBills) } + + @Query("UPDATE detected_bills SET isBillSeen = :isSeen") + suspend fun updateAllBillsSeenStatus(isSeen: Boolean) } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DeleteDetectedBillShimmerState.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DeleteDetectedBillShimmerState.kt new file mode 100644 index 0000000000..be047744f0 --- /dev/null +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DeleteDetectedBillShimmerState.kt @@ -0,0 +1,15 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.feature.detectedbills.model.view + +import com.navi.bbps.common.model.view.DetectedBillEntity + +data class DeleteDetectedBillShimmerState( + val deleteBillInProgress: Boolean = false, + val detectedBillItem: DetectedBillEntity? = null, +) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DetectedBillsBottomSheetType.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DetectedBillsBottomSheetType.kt index a69091ba17..2be1a4b825 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DetectedBillsBottomSheetType.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/model/view/DetectedBillsBottomSheetType.kt @@ -7,15 +7,27 @@ package com.navi.bbps.feature.detectedbills.model.view +import androidx.annotation.DrawableRes import com.navi.base.utils.EMPTY import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.DetectedBillStatus +import com.navi.bbps.feature.category.ui.DetectedBillId sealed class DetectedBillsBottomSheetType { data class MenuOptions(val detectedBillEntity: DetectedBillEntity) : DetectedBillsBottomSheetType() + data class DetectedBills( + val detectedBills: List, + val onBillCheckboxClicked: (DetectedBillId) -> Unit, + val onAddBillsClicked: () -> Unit, + val onViewAllBillsClicked: () -> Unit, + ) : DetectedBillsBottomSheetType() + + data class Loading(@DrawableRes val iconResId: Int, val rolodexTitles: List) : + DetectedBillsBottomSheetType() + companion object { fun createEmptyDetectedBillEntity(): DetectedBillEntity { return DetectedBillEntity( @@ -30,6 +42,7 @@ sealed class DetectedBillsBottomSheetType { categoryId = EMPTY, categoryTitle = EMPTY, detectedBillStatus = DetectedBillStatus.DETECTED_BILL_STATUS_PENDING, + isBillSeen = false, ) } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsBottomSheetContent.kt index 7e387e778b..4dafc6f649 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsBottomSheetContent.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsBottomSheetContent.kt @@ -28,6 +28,7 @@ import androidx.compose.ui.unit.sp import com.navi.bbps.R import com.navi.bbps.common.model.view.DetectedBillEntity import com.navi.bbps.common.theme.NaviBbpsColor +import com.navi.bbps.common.ui.TitleDescriptionWithLinearProgressBar import com.navi.bbps.feature.detectedbills.model.view.DetectedBillsBottomSheetType import com.navi.design.font.FontWeightEnum import com.navi.design.font.getFontWeight @@ -38,6 +39,7 @@ import com.navi.naviwidgets.extensions.NaviText fun DetectedBillsBottomSheetContent( detectedBillsBottomSheetType: DetectedBillsBottomSheetType, onDeleteAccountBillClicked: (DetectedBillEntity) -> Unit, + loadingBottomSheetIndicatorProgress: Float, ) { when (detectedBillsBottomSheetType) { is DetectedBillsBottomSheetType.MenuOptions -> { @@ -47,6 +49,20 @@ fun DetectedBillsBottomSheetContent( } ) } + is DetectedBillsBottomSheetType.DetectedBills -> { + DetectedBillsBottomSheetContent( + detectedBillsBottomSheetType = detectedBillsBottomSheetType, + onDeleteAccountBillClicked = {}, + loadingBottomSheetIndicatorProgress = loadingBottomSheetIndicatorProgress, + ) + } + is DetectedBillsBottomSheetType.Loading -> { + TitleDescriptionWithLinearProgressBar( + iconResId = detectedBillsBottomSheetType.iconResId, + rolodexTitleList = detectedBillsBottomSheetType.rolodexTitles, + indicatorProgress = loadingBottomSheetIndicatorProgress, + ) + } } } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsScreen.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsScreen.kt index 3b6a61ad98..2d15ee54f0 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsScreen.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/detectedbills/ui/DetectedBillsScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.ModalBottomSheetValue @@ -52,18 +54,24 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.navi.base.utils.EMPTY import com.navi.base.utils.orFalse import com.navi.bbps.R +import com.navi.bbps.bbpsShimmerEffect import com.navi.bbps.common.BULLET import com.navi.bbps.common.NaviBbpsAnalytics import com.navi.bbps.common.NaviBbpsDimens import com.navi.bbps.common.NaviBbpsScreen +import com.navi.bbps.common.gmail.model.GmailAccessState import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.DetectedBillStatus import com.navi.bbps.common.theme.NaviBbpsColor -import com.navi.bbps.common.ui.ImageWithCircularBackground +import com.navi.bbps.common.ui.BbpsCircleImage +import com.navi.bbps.common.ui.DashedHorizontalDivider import com.navi.bbps.common.ui.NaviBbpsHeader import com.navi.bbps.common.ui.NaviBbpsModalBottomSheetLayout +import com.navi.bbps.common.ui.OriginLandingWidget import com.navi.bbps.common.ui.OutlineRoundedThemeButton import com.navi.bbps.common.utils.BbpsSnackBarPredefinedConfig +import com.navi.bbps.common.utils.OriginWidgetStatus import com.navi.bbps.common.utils.clearBackStackUpToAndNavigate import com.navi.bbps.customHide import com.navi.bbps.entry.NaviBbpsActivity @@ -73,6 +81,7 @@ import com.navi.bbps.feature.destinations.CustomerDataInputScreenDestination import com.navi.bbps.feature.destinations.MyBillsScreenDestination import com.navi.bbps.feature.detectedbills.DetectedBillsViewModel import com.navi.bbps.feature.detectedbills.model.view.AddDetectedBillProgressState +import com.navi.bbps.feature.detectedbills.model.view.DeleteDetectedBillShimmerState import com.navi.bbps.feature.detectedbills.model.view.DetectedBillCustomerInputResult import com.navi.common.R as CommonR import com.navi.common.customview.LoaderRoundedButton @@ -125,9 +134,20 @@ fun DetectedBillsScreen( val showSnackBar by detectedBillsViewModel.showSnackBar.collectAsStateWithLifecycle() val isUserActionBlocked by detectedBillsViewModel.isUserActionBlocked.collectAsStateWithLifecycle() + val deleteDetectedBillShimmerState by + detectedBillsViewModel.deleteDetectedBillShimmerState.collectAsStateWithLifecycle() val isAddDetectedBillInProgress by detectedBillsViewModel.isAddDetectedBillInProgress.collectAsStateWithLifecycle() + val originSessionAttributes by + detectedBillsViewModel.originSessionAttributes.collectAsStateWithLifecycle() + val isOriginEmailWidgetVisible by + detectedBillsViewModel.isOriginEmailWidgetVisible.collectAsStateWithLifecycle() + + val loadingIndicatorProgress by + detectedBillsViewModel.originBillDetectionHandler.loadingIndicatorProgress + .collectAsStateWithLifecycle() + val bbpsSnackBarPredefinedConfig = remember { BbpsSnackBarPredefinedConfig() } val context = LocalContext.current @@ -139,6 +159,7 @@ fun DetectedBillsScreen( NavResult.Canceled -> { // Do nothing } + is NavResult.Value -> { val detectedBillCustomerInputResult = result.value detectedBillsViewModel.onDetectedBillAddedResult(detectedBillCustomerInputResult) @@ -168,17 +189,30 @@ fun DetectedBillsScreen( Unit } + LaunchedEffect(Unit) { + detectedBillsViewModel.openBottomSheet.collect { + when (it) { + true -> openSheet() + false -> closeSheet() + null -> {} + } + } + } + val onBackClick = { when { fullScreenLoaderState?.isVisible.orFalse() -> { // no-op } + bottomSheetState.isVisible -> { closeSheet() } + isRootScreen -> { naviBbpsActivity.finish() } + else -> { navigator.navigateUp() } @@ -218,6 +252,7 @@ fun DetectedBillsScreen( inclusive = true, ) } + else -> { navigator.clearBackStackUpToAndNavigate( destination = direction, @@ -250,11 +285,76 @@ fun DetectedBillsScreen( } } + val launcher = + naviBbpsActivity.gmailAccessSignInManager.createSignInResultLauncher { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.UserCancelled -> { + naviBbpsAnalytics.onGmailAccessSignInCancelled( + source = source, + sessionAttribute = detectedBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AccessGranted -> { + detectedBillsViewModel.showLoadingBottomSheet() + openSheet() + detectedBillsViewModel.startDetectingBills( + detectedBillSource = DetectedBillSource.EMAIL, + googleToken = gmailAccessState.signInResponse.authCode, + email = gmailAccessState.signInResponse.emailId, + ) + } + + is GmailAccessState.UnexpectedSignInResult -> { + naviBbpsAnalytics.onGmailAccessSignInFailed( + source = source, + sessionAttribute = detectedBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AuthorizationException -> {} + + else -> {} + } + } + + val onOriginWidgetAddBillsClicked = { + naviBbpsActivity.gmailAccessSignInManager.signIn( + launcher = launcher, + callback = { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.NotInitialized -> { + naviBbpsAnalytics.gmailNotInitialized( + source = source, + sessionAttribute = + detectedBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.ServerCredentialsMissing -> { + naviBbpsAnalytics.gmailServerCredentialsMissing( + source = source, + sessionAttribute = + detectedBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + else -> {} + } + }, + ) + } + NaviBbpsModalBottomSheetLayout( sheetContent = { DetectedBillsBottomSheetContent( detectedBillsBottomSheetType = detectedBillsBottomSheetType, onDeleteAccountBillClicked = onDeleteAccountBillClicked, + loadingBottomSheetIndicatorProgress = loadingIndicatorProgress, ) }, sheetState = bottomSheetState, @@ -280,56 +380,81 @@ fun DetectedBillsScreen( Column( modifier = Modifier.padding(innerPadding) - .padding(start = 16.dp, end = 16.dp, bottom = 16.dp) .verticalScroll(state = rememberScrollState()) ) { - NaviText( - text = - pluralStringResource( - id = R.plurals.bbps_bills_found_with_count, - count = detectedBills.size, - formatArgs = arrayOf(detectedBills.size), - ), - fontSize = 20.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), - color = NaviBbpsColor.textPrimary, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - ) + Column( + modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 16.dp) + ) { + NaviText( + text = + pluralStringResource( + id = R.plurals.bbps_bills_found_with_count, + count = detectedBills.size, + formatArgs = arrayOf(detectedBills.size), + ), + fontSize = 20.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_DEMI_BOLD), + color = NaviBbpsColor.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(8.dp)) - NaviText( - text = stringResource(id = R.string.bbps_detected_bills_description), - fontSize = 12.sp, - fontFamily = naviFontFamily, - fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), - color = NaviBbpsColor.textTertiary, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Start, - ) + NaviText( + text = + stringResource(id = R.string.bbps_detected_bills_description), + fontSize = 12.sp, + fontFamily = naviFontFamily, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + color = NaviBbpsColor.textTertiary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Start, + ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) - DetectedBillsList( - onAddBillClicked = { bill -> - detectedBillsViewModel.onAddBillClicked(bill) - }, - bills = detectedBills, - onDeleteAccountMenuClicked = onDeleteAccountMenuClicked, - isAddDetectedBillInProgress = isAddDetectedBillInProgress, - ) - Spacer(modifier = Modifier.height(16.dp)) + DetectedBillsList( + onAddBillClicked = { bill -> + detectedBillsViewModel.onAddBillClicked(bill) + }, + bills = detectedBills, + onDeleteAccountMenuClicked = onDeleteAccountMenuClicked, + isAddDetectedBillInProgress = isAddDetectedBillInProgress, + deleteDetectedBillShimmerState = deleteDetectedBillShimmerState, + ) + + Spacer(modifier = Modifier.height(8.dp)) + } + + if (isOriginEmailWidgetVisible) { + DashedHorizontalDivider( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + color = NaviBbpsColor.borderDefault, + thickness = 1.dp, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + OriginLandingWidget( + onAddClicked = onOriginWidgetAddBillsClicked, + originWidgetStatus = OriginWidgetStatus.EMAIL_FTU, + detectedBills = originSessionAttributes.detectedBills, + ) + + Spacer(modifier = Modifier.height(24.dp)) + } } } }, bottomBar = { if (!fullScreenLoaderState?.isVisible.orFalse()) { - Spacer(modifier = Modifier.height(32.dp)) Column { + Spacer(modifier = Modifier.height(16.dp)) + if (!hasPendingOrNonRespondingBills) { Row( modifier = Modifier.fillMaxWidth(), @@ -452,6 +577,7 @@ fun DetectedBillsList( onDeleteAccountMenuClicked: (DetectedBillEntity) -> Unit, bills: List, isAddDetectedBillInProgress: AddDetectedBillProgressState, + deleteDetectedBillShimmerState: DeleteDetectedBillShimmerState, ) { Column(modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) { bills.forEach { bill -> @@ -460,6 +586,7 @@ fun DetectedBillsList( onDeleteAccountMenuClicked = onDeleteAccountMenuClicked, onAddBillClicked = onAddBillClicked, isAddDetectedBillInProgress = isAddDetectedBillInProgress, + deleteDetectedBillShimmerState = deleteDetectedBillShimmerState, ) } } @@ -471,6 +598,7 @@ private fun DetectedBillItem( onDeleteAccountMenuClicked: (DetectedBillEntity) -> Unit, onAddBillClicked: (DetectedBillEntity) -> Unit, isAddDetectedBillInProgress: AddDetectedBillProgressState, + deleteDetectedBillShimmerState: DeleteDetectedBillShimmerState, ) { val showLoader = remember(isAddDetectedBillInProgress) { @@ -478,6 +606,88 @@ private fun DetectedBillItem( isAddDetectedBillInProgress.selectedDetectedBillItem == bill } + if ( + deleteDetectedBillShimmerState.deleteBillInProgress && + deleteDetectedBillShimmerState.detectedBillItem == bill + ) { + DetectedBillShimmerItem() + } else { + DetectedBillItemContent( + bill = bill, + onDeleteAccountMenuClicked = onDeleteAccountMenuClicked, + onAddBillClicked = onAddBillClicked, + showLoader = showLoader, + ) + } +} + +@Composable +fun DetectedBillShimmerItem() { + Column( + modifier = + Modifier.fillMaxWidth() + .border( + width = 1.dp, + color = NaviBbpsColor.borderDefault, + shape = RoundedCornerShape(4.dp), + ) + .padding(top = 18.dp) + ) { + Row( + modifier = + Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), + verticalAlignment = Alignment.CenterVertically, + ) { + Box(modifier = Modifier.size(40.dp).clip(CircleShape).bbpsShimmerEffect()) + + Spacer(modifier = Modifier.width(8.dp)) + + Column(modifier = Modifier.weight(1f).align(Alignment.CenterVertically)) { + Box( + modifier = + Modifier.fillMaxWidth(0.7f) + .height(12.dp) + .clip(RoundedCornerShape(2.dp)) + .bbpsShimmerEffect() + ) + + Spacer(modifier = Modifier.height(2.dp)) + + Box( + modifier = + Modifier.fillMaxWidth(0.5f) + .height(12.dp) + .clip(RoundedCornerShape(2.dp)) + .bbpsShimmerEffect() + ) + } + + Spacer(modifier = Modifier.width(16.dp)) + + Box( + modifier = + Modifier.width(70.dp) + .height(32.dp) + .clip(RoundedCornerShape(16.dp)) + .bbpsShimmerEffect() + ) + + Spacer(modifier = Modifier.width(16.dp)) + + Box(modifier = Modifier.size(24.dp).clip(CircleShape).bbpsShimmerEffect()) + } + + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun DetectedBillItemContent( + bill: DetectedBillEntity, + onDeleteAccountMenuClicked: (DetectedBillEntity) -> Unit, + onAddBillClicked: (DetectedBillEntity) -> Unit, + showLoader: Boolean, +) { Column( modifier = Modifier.fillMaxWidth() @@ -492,9 +702,9 @@ private fun DetectedBillItem( Row( modifier = Modifier.fillMaxWidth().padding(horizontal = NaviBbpsDimens.horizontalMargin), - verticalAlignment = Alignment.CenterVertically, + verticalAlignment = Alignment.Top, ) { - ImageWithCircularBackground( + BbpsCircleImage( imageUrl = bill.billerLogo, placeholderIconResId = CommonR.drawable.navi_common_ic_biller_placeholder, boxSize = 40.dp, @@ -535,10 +745,43 @@ private fun DetectedBillItem( maxLines = 1, fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), overflow = TextOverflow.Ellipsis, - lineHeight = 20.sp, + lineHeight = 16.sp, ) + Spacer(modifier = Modifier.height(6.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Image( + painter = + if (bill.detectedBillSource == DetectedBillSource.SMS) { + painterResource(id = R.drawable.ic_bbps_sms_icon) + } else { + painterResource(id = R.drawable.ic_bbps_email_icon) + }, + contentDescription = "", + modifier = Modifier.padding(top = 2.dp).align(Alignment.CenterVertically), + ) + Spacer(modifier = Modifier.width(4.dp)) + NaviText( + text = + if (bill.detectedBillSource == DetectedBillSource.SMS) { + stringResource(id = R.string.bbps_bills_found_via_sms) + } else { + stringResource(id = R.string.bbps_bills_found_via_email) + }, + color = NaviBbpsColor.textTertiary, + fontFamily = naviFontFamily, + fontSize = 10.sp, + maxLines = 1, + fontWeight = getFontWeight(FontWeightEnum.NAVI_BODY_REGULAR), + overflow = TextOverflow.Ellipsis, + lineHeight = 16.sp, + ) + } } Spacer(modifier = Modifier.width(16.dp)) + OutlineRoundedThemeButton( text = stringResource(id = R.string.bbps_add), onClick = { onAddBillClicked(bill) }, @@ -554,7 +797,7 @@ private fun DetectedBillItem( contentDescription = stringResource(id = R.string.bbps_kebab_three_dots_option_icon), modifier = - Modifier.size(24.dp).align(Alignment.CenterVertically).clickableDebounce { + Modifier.padding(top = 6.dp).size(24.dp).clickableDebounce { onDeleteAccountMenuClicked(bill) }, ) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/MyBillsViewModel.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/MyBillsViewModel.kt index 9047c59c46..66b08b58c3 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/MyBillsViewModel.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/MyBillsViewModel.kt @@ -14,11 +14,9 @@ import com.navi.base.model.CtaData import com.navi.base.utils.EMPTY import com.navi.base.utils.NaviNetworkConnectivity import com.navi.base.utils.ResourceProvider -import com.navi.base.utils.orFalse import com.navi.base.utils.orZero import com.navi.base.utils.retry import com.navi.bbps.R -import com.navi.bbps.common.AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME import com.navi.bbps.common.APP_VERSION_CODE import com.navi.bbps.common.BILLER_UNIQUE_ID import com.navi.bbps.common.CATEGORY_ID_MOBILE_PREPAID @@ -30,14 +28,14 @@ import com.navi.bbps.common.RETRY_INTERVAL_IN_SECONDS import com.navi.bbps.common.TXN_AMOUNT import com.navi.bbps.common.model.NaviBbpsVmData import com.navi.bbps.common.model.config.NaviBbpsDefaultConfig -import com.navi.bbps.common.model.network.BbpsABTestingItemResponse +import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.NaviPermissionResult import com.navi.bbps.common.model.view.RefreshBillState import com.navi.bbps.common.repository.BbpsCommonRepository import com.navi.bbps.common.session.NaviBbpsSessionHelper import com.navi.bbps.common.usecase.FindLastOrderWithSuccessfulPaymentUseCase -import com.navi.bbps.common.usecase.GetABTestingExperimentUseCase -import com.navi.bbps.common.usecase.NaviBbpsConfigUseCase +import com.navi.bbps.common.usecase.OriginExperimentUtils import com.navi.bbps.common.usecase.UploadUserDataUseCase import com.navi.bbps.common.utils.BbpsOriginSessionHandler import com.navi.bbps.common.utils.BillDetailsResponseToEntityMapper @@ -49,7 +47,7 @@ import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getBbpsMetricInfo import com.navi.bbps.common.utils.NaviBbpsCommonUtils.getNormalisedPhoneNumber import com.navi.bbps.common.utils.OriginSessionAttributes import com.navi.bbps.common.utils.OriginWidgetStatus -import com.navi.bbps.common.utils.getDefaultConfig +import com.navi.bbps.common.utils.getApiBillsWithSeenStatusApplied import com.navi.bbps.common.viewmodel.NaviBbpsBaseVM import com.navi.bbps.common.viewmodel.OriginBillDetectionHandler import com.navi.bbps.feature.billerlist.model.view.BillerItemEntity @@ -62,6 +60,7 @@ import com.navi.bbps.feature.customerinput.model.network.DeviceDetails import com.navi.bbps.feature.customerinput.model.view.BillerDetailsEntity import com.navi.bbps.feature.customerinput.model.view.CustomerParamsExtraData import com.navi.bbps.feature.destinations.CustomerDataInputScreenDestination +import com.navi.bbps.feature.destinations.DetectedBillsScreenDestination import com.navi.bbps.feature.destinations.MyBillHistoryDetailsScreenDestination import com.navi.bbps.feature.destinations.PayBillScreenDestination import com.navi.bbps.feature.destinations.PrepaidRechargeScreenDestination @@ -94,8 +93,10 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @HiltViewModel class MyBillsViewModel @@ -112,15 +113,15 @@ constructor( private val naviNetworkConnectivity: NaviNetworkConnectivity, private val phoneContactManager: Lazy, private val myBillsSyncJob: MyBillsSyncJob, - private val naviBbpsConfigUseCase: NaviBbpsConfigUseCase, private val bbpsCommonRepository: BbpsCommonRepository, private val resourceProvider: ResourceProvider, - private val getABTestingExperimentUseCase: GetABTestingExperimentUseCase, val originBillDetectionHandler: OriginBillDetectionHandler, private val originSessionHandler: BbpsOriginSessionHandler, internal val uploadUserDataUseCase: UploadUserDataUseCase, private val detectedBillsRepository: DetectedBillsRepository, - private val findLastOrderWithSuccessfulPaymentUseCase: FindLastOrderWithSuccessfulPaymentUseCase, + private val findLastOrderWithSuccessfulPaymentUseCase: + FindLastOrderWithSuccessfulPaymentUseCase, + private val originExperimentUtils: OriginExperimentUtils, ) : NaviBbpsBaseVM( naviBbpsVmData = NaviBbpsVmData(screen = NaviBbpsScreen.NAVI_BBPS_MY_SAVED_BILLS) @@ -181,8 +182,8 @@ constructor( } init { + viewModelScope.launch(dispatcherProvider.io) { fetchOriginExperiment() } recordScreenLandTime(screen = naviBbpsVmData.screen.screenName) - initConfig() fetchMySavedBills( onFetchSuccess = { viewModelScope.launch(Dispatchers.IO) { @@ -200,50 +201,45 @@ constructor( _isBottomSheetCancellable.update { true } } - private fun initConfig() { - viewModelScope.launch(Dispatchers.IO) { - launch { - naviBbpsDefaultConfig = - naviBbpsConfigUseCase.getDefaultConfig(naviBbpsVmData.screen.screenName) - } - launch { - getABTestingExperimentUseCase.executeAsFlow( - experimentName = AB_TESTING_PROJECT_ORIGIN_EXPERIMENT_NAME, - onValueUpdated = { abTestingItemResponse -> - onOriginExperimentResultFetched(abTestingItemResponse) - }, - ) - } + private suspend fun fetchOriginExperiment() { + val isExperimentEnabled = originExperimentUtils.isOriginExperimentEnabled() + + naviBbpsAnalytics.onProjectOriginExperimentFetched( + initialSource = initialSource, + isExperimentEnabled = isExperimentEnabled, + ) + if (isExperimentEnabled) { + onOriginExperimentResultFetched() } } - private fun onOriginExperimentResultFetched(abTestingItemResponse: BbpsABTestingItemResponse) { + private fun onOriginExperimentResultFetched() { viewModelScope.launch(dispatcherProvider.io) { - if (!abTestingItemResponse.isEnabled.orFalse()) { - return@launch - } - - updateOriginAttributes() - - naviBbpsAnalytics.onProjectOriginExperimentFetched( - initialSource = initialSource, - isExperimentEnabled = abTestingItemResponse.isEnabled.orFalse(), - ) - - detectedBillsRepository.getCachedDetectedBillsAsFlow().collect { + detectedBillsRepository.getCachedDetectedBillsAsFlow().distinctUntilChanged().collect { updateOriginAttributes() } } } - private suspend fun updateOriginAttributes() { - val originSessionAttributes = - originSessionHandler.getOriginSessionAttributes(naviBbpsVmData.screen) - _originSessionAttributes.update { originSessionAttributes } - } + private suspend fun updateOriginAttributes(): OriginSessionAttributes = + withContext(dispatcherProvider.io) { + val updatedAttributes = + originSessionHandler.getOriginAttributes( + naviBbpsVmData.screen, + originExperimentUtils.isOriginEmailSubExperimentEnabled(), + ) + _originSessionAttributes.update { updatedAttributes } + updatedAttributes + } - fun updateSnackBarState(show: Boolean, messageId: Int = R.string.bbps_copied_to_clipboard) { - _snackBarState.update { SnackBarState(show = show, messageId = messageId) } + fun updateSnackBarState( + show: Boolean, + messageId: Int = R.string.bbps_copied_to_clipboard, + leadingIconResId: Int = R.drawable.ic_success_green, + ) { + _snackBarState.update { + SnackBarState(show = show, messageId = messageId, leadingIconResId = leadingIconResId) + } } fun updateMySavedBills() { @@ -508,6 +504,7 @@ constructor( myUnpaidBills = updatedUnpaidBills, ) } + else -> currentState } @@ -801,11 +798,60 @@ constructor( } } - fun startDetectingBills() { + fun handleOriginRedirectionForRtu(detectedBills: List) { + viewModelScope.safeLaunch(dispatcherProvider.io) { + val filteredList = + detectedBillsRepository.getApiBillsWithSeenStatusApplied( + detectedBillsFromNetwork = detectedBills + ) + + val newDetectedBills = + // filtering only bills whose isBillSeen is false as newly detected bills + filteredList.filter { it.isBillSeen.not() } + + originBillDetectionHandler.refreshDetectedBillsAndSelection( + detectedBills = filteredList, + newDetectedBills = newDetectedBills, + ) + + if (newDetectedBills.isEmpty()) { + _navigateToNextScreen.emit( + DetectedBillsScreenDestination( + isRootScreen = false, + source = naviBbpsVmData.screen.screenName, + initialSource = initialSource, + ) + ) + } else { + detectedBillsRepository.updateAllBillsSeenStatus(isSeen = true) + _myBillsBottomSheetType.update { + MyBillsBottomSheetType.DetectedBills( + detectedBills = newDetectedBills, + onBillCheckboxClicked = { detectedBillId -> + originBillDetectionHandler.onDetectedBillCheckedChanged(detectedBillId) + }, + onAddBillsClicked = ::onAddDetectedBillsClicked, + onViewAllBillsClicked = ::onViewAllBillsClicked, + allDetectedBillsCount = detectedBills.size, + ) + } + updateOpenBottomSheet(isOpen = true) + } + } + } + + fun startDetectingBills( + detectedBillSource: DetectedBillSource = DetectedBillSource.SMS, + googleToken: String? = null, + email: String? = null, + ) { viewModelScope.launch(dispatcherProvider.io) { originBillDetectionHandler.startDetectingBills( naviBbpsBaseVM = this@MyBillsViewModel, - originSessionHandler = originSessionHandler, + coroutineScope = viewModelScope, + permission = detectedBillSource.name, + googleToken = googleToken, + email = email, naviBbpsDefaultConfig = naviBbpsDefaultConfig, updateOpenBottomSheet = { isOpen -> updateOpenBottomSheet(isOpen) }, updateBottomSheetCancellable = { isCancellable -> @@ -820,33 +866,42 @@ constructor( ) _originSessionAttributes.update { originSessionAttributes.value.copy( - originWidgetStatus = OriginWidgetStatus.RTUE, + originWidgetStatus = OriginWidgetStatus.SMS_RTU, detectedBills = detectedBills, - isOriginNuxSeen = true, - ) - } - _myBillsBottomSheetType.update { - MyBillsBottomSheetType.DetectedBills( - detectedBills = detectedBills, - onBillCheckboxClicked = { detectedBillId -> - originBillDetectionHandler.onDetectedBillCheckedChanged( - detectedBillId - ) - }, - onAddBillsClicked = ::onAddDetectedBillsClicked, + isOriginSmsNuxSeen = true, ) } + handleOriginRedirectionForRtu(detectedBills = detectedBills) }, onNoBillsDetectedBottomSheet = { - _originSessionAttributes.update { - originSessionAttributes.value.copy( - originWidgetStatus = OriginWidgetStatus.HIDDEN, - isOriginNuxSeen = true, - ) - } + updateSessionAttributesInCaseOfNoBills(detectedBillSource) _myBillsBottomSheetType.update { MyBillsBottomSheetType.NoBillDetectedError } }, onErrorResponse = { updateOpenBottomSheet(false) }, + onNotifyLaterBottomSheet = { + updateSessionAttributesInCaseOfNoBills(detectedBillSource) + }, + ) + } + } + + private suspend fun updateSessionAttributesInCaseOfNoBills( + detectedBillSource: DetectedBillSource + ) { + _originSessionAttributes.update { + it.copy( + originWidgetStatus = + when (detectedBillSource) { + DetectedBillSource.SMS -> + if (originExperimentUtils.isOriginEmailSubExperimentEnabled()) { + OriginWidgetStatus.EMAIL_FTU + } else { + OriginWidgetStatus.HIDDEN + } + + DetectedBillSource.EMAIL -> OriginWidgetStatus.HIDDEN + DetectedBillSource.UNKNOWN -> return // no update needed + } ) } } @@ -863,8 +918,7 @@ constructor( updateOpenBottomSheet(false) originBillDetectionHandler.onAddDetectedBillsClicked( naviBbpsBaseVM = this@MyBillsViewModel, - initialSource = initialSource, - onBillsAdded = { newAddedBills -> + onBillsAdded = { newAddedBills, isCallInterrupted -> naviBbpsAnalytics.onFullScreenDetectedBillsAddedScreen( initialSource = initialSource, source = naviBbpsVmData.screen.screenName, @@ -880,15 +934,39 @@ constructor( } } + private fun onViewAllBillsClicked() { + viewModelScope.launch(dispatcherProvider.io) { + _navigateToNextScreen.emit( + DetectedBillsScreenDestination( + isRootScreen = false, + source = naviBbpsVmData.screen.screenName, + initialSource = initialSource, + ) + ) + } + } + private suspend fun updateOpenBottomSheet(isOpen: Boolean) { _openBottomSheet.update { isOpen } delay(50) _openBottomSheet.update { null } } + fun onBackClickDuringFullScreenLoader() { + viewModelScope.launch(dispatcherProvider.io) { + naviBbpsAnalytics.onFullScreenLoaderBackClicked( + initialSource = initialSource, + source = naviBbpsVmData.screen.screenName, + sessionAttribute = naviBbpsSessionHelper.getNaviBbpsSessionAttributes(), + ) + originBillDetectionHandler.onFullScreenLoaderDismissed() + } + } + data class SnackBarState( val show: Boolean, val messageId: Int = R.string.bbps_copied_to_clipboard, + val leadingIconResId: Int = R.drawable.ic_success_green, ) suspend fun findLastOrderWithSuccessfulPayment(billId: String?): OrderEntity? = diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/model/view/MyBillsBottomSheetType.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/model/view/MyBillsBottomSheetType.kt index bc6cd51f5a..7ad550e226 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/model/view/MyBillsBottomSheetType.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/model/view/MyBillsBottomSheetType.kt @@ -58,6 +58,8 @@ sealed class MyBillsBottomSheetType { val detectedBills: List, val onBillCheckboxClicked: (DetectedBillId) -> Unit, val onAddBillsClicked: () -> Unit, + val onViewAllBillsClicked: () -> Unit, + val allDetectedBillsCount: Int, ) : MyBillsBottomSheetType() data object NoBillDetectedError : MyBillsBottomSheetType() diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsBottomSheetContent.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsBottomSheetContent.kt index d7b200095e..2c7b79930a 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsBottomSheetContent.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsBottomSheetContent.kt @@ -200,10 +200,12 @@ fun MyBillsBottomSheetContent( is MyBillsBottomSheetType.DetectedBills -> { DetectedBillsBottomSheetContent( detectedBills = myBillsBottomSheetType.detectedBills, - closeSheet = closeSheet, - onDetectedBillCheckboxClicked = myBillsBottomSheetType.onBillCheckboxClicked, - onContinueToAddClicked = myBillsBottomSheetType.onAddBillsClicked, selectedDetectedBills = selectedDetectedBillIds, + onContinueToAddClicked = myBillsBottomSheetType.onAddBillsClicked, + onViewAllBillsClicked = myBillsBottomSheetType.onViewAllBillsClicked, + onDetectedBillCheckboxClicked = myBillsBottomSheetType.onBillCheckboxClicked, + closeSheet = closeSheet, + allDetectedBillsCount = myBillsBottomSheetType.allDetectedBillsCount, ) } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsScreen.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsScreen.kt index 303aa54ccf..767a3615dd 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsScreen.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/MyBillsScreen.kt @@ -35,6 +35,8 @@ import com.navi.base.utils.orFalse import com.navi.bbps.R import com.navi.bbps.common.NaviBbpsAnalytics import com.navi.bbps.common.NaviBbpsScreen +import com.navi.bbps.common.gmail.model.GmailAccessState +import com.navi.bbps.common.model.view.DetectedBillSource import com.navi.bbps.common.model.view.NaviPermissionResult import com.navi.bbps.common.model.view.rememberMultiplePermissions import com.navi.bbps.common.theme.NaviBbpsColor @@ -43,7 +45,8 @@ import com.navi.bbps.common.ui.NaviBbpsModalBottomSheetLayout import com.navi.bbps.common.ui.SetStatusBarColor import com.navi.bbps.common.utils.NaviBbpsCommonUtils import com.navi.bbps.common.utils.NaviBbpsCommonUtils.shouldRedirectToHomeScreen -import com.navi.bbps.common.utils.OriginWidgetStatus.FTUE +import com.navi.bbps.common.utils.OriginWidgetStatus +import com.navi.bbps.common.utils.OriginWidgetStatus.SMS_FTU import com.navi.bbps.common.utils.SnackBarPredefinedConfig import com.navi.bbps.common.utils.launchPermissionSettingsScreen import com.navi.bbps.entry.NaviBbpsActivity @@ -64,6 +67,7 @@ import com.navi.common.model.ModuleNameV2 import com.navi.common.navigation.rememberNavigatorWithResultAndParams import com.navi.design.snackbar.ErrorSnackBar import com.navi.design.snackbar.SuccessSnackBar +import com.navi.naviwidgets.R as WidgetsR import com.navi.naviwidgets.extensions.isFirstItemVisible import com.ramcosta.composedestinations.annotation.Destination import com.ramcosta.composedestinations.navigation.DestinationsNavigator @@ -211,7 +215,12 @@ fun MyBillsScreen( val onBackClick = { when { fullScreenLoaderState?.isVisible.orFalse() -> { - // no-op + myBillsViewModel.onBackClickDuringFullScreenLoader() + myBillsViewModel.updateSnackBarState( + show = true, + messageId = R.string.bbps_we_will_once_we_find_bills, + leadingIconResId = WidgetsR.drawable.ic_info_gray, + ) } bottomSheetState.isVisible -> { closeSheet() @@ -325,8 +334,43 @@ fun MyBillsScreen( Unit } + val launcher = + naviBbpsActivity.gmailAccessSignInManager.createSignInResultLauncher { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.UserCancelled -> { + naviBbpsAnalytics.onGmailAccessSignInCancelled( + source = source, + sessionAttribute = myBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AccessGranted -> { + myBillsViewModel.showLoadingBottomSheet() + openSheet() + myBillsViewModel.startDetectingBills( + detectedBillSource = DetectedBillSource.EMAIL, + googleToken = gmailAccessState.signInResponse.authCode, + email = gmailAccessState.signInResponse.emailId, + ) + } + + is GmailAccessState.UnexpectedSignInResult -> { + naviBbpsAnalytics.onGmailAccessSignInFailed( + source = source, + sessionAttribute = myBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.AuthorizationException -> {} + + else -> {} + } + } + val onOriginWidgetAddBillsClicked = { - if (originSessionAttributes.originWidgetStatus == FTUE) { + if (originSessionAttributes.originWidgetStatus == SMS_FTU) { if (fetchSmsPermissionState.allPermissionsGranted) { myBillsViewModel.showLoadingBottomSheet() openSheet() @@ -334,6 +378,38 @@ fun MyBillsScreen( } else { requestPermission() } + } else if ( + originSessionAttributes.originWidgetStatus == OriginWidgetStatus.SMS_RTU || + originSessionAttributes.originWidgetStatus == OriginWidgetStatus.EMAIL_RTU + ) { + myBillsViewModel.handleOriginRedirectionForRtu( + detectedBills = originSessionAttributes.detectedBills + ) + } else if (originSessionAttributes.originWidgetStatus == OriginWidgetStatus.EMAIL_FTU) { + naviBbpsActivity.gmailAccessSignInManager.signIn( + launcher = launcher, + callback = { gmailAccessState -> + when (gmailAccessState) { + is GmailAccessState.NotInitialized -> { + naviBbpsAnalytics.gmailNotInitialized( + source = source, + sessionAttribute = myBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + is GmailAccessState.ServerCredentialsMissing -> { + naviBbpsAnalytics.gmailServerCredentialsMissing( + source = source, + sessionAttribute = myBillsViewModel.getNaviBbpsSessionAttributes(), + initialSource = initialSource, + ) + } + + else -> {} + } + }, + ) } else { navigator.navigate( DetectedBillsScreenDestination( @@ -494,6 +570,7 @@ fun MyBillsScreen( SnackBarPredefinedConfig.successConfig( title = stringResource(id = snackBarState.messageId), trailingIconResId = null, + leadingIconResId = snackBarState.leadingIconResId, ), onDismissed = { myBillsViewModel.updateSnackBarState(show = false) }, ) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderEmptyMyBillsScreen.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderEmptyMyBillsScreen.kt index d783a89885..f5b16876e1 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderEmptyMyBillsScreen.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderEmptyMyBillsScreen.kt @@ -60,8 +60,7 @@ fun RenderEmptyMyBillsScreen( if (originSessionAttributes.originWidgetStatus != OriginWidgetStatus.HIDDEN) { OriginLandingWidget( onAddClicked = onOriginWidgetAddBillsClicked, - isFirstTimeUser = - originSessionAttributes.originWidgetStatus == OriginWidgetStatus.FTUE, + originWidgetStatus = originSessionAttributes.originWidgetStatus, detectedBills = originSessionAttributes.detectedBills, ) } diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderMyBillsScreen.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderMyBillsScreen.kt index fd01f3c05a..48b414104a 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderMyBillsScreen.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/mybills/ui/RenderMyBillsScreen.kt @@ -138,8 +138,7 @@ fun RenderMyBillsScreen( item { OriginLandingWidget( onAddClicked = onOriginWidgetAddBillsClicked, - isFirstTimeUser = - originSessionAttributes.originWidgetStatus == OriginWidgetStatus.FTUE, + originWidgetStatus = originSessionAttributes.originWidgetStatus, detectedBills = originSessionAttributes.detectedBills, ) Spacer(modifier = Modifier.height(24.dp)) diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/permission/ui/PermissionScreen.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/permission/ui/PermissionScreen.kt index f150f1e7d0..d9b4ba5ebc 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/permission/ui/PermissionScreen.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/feature/permission/ui/PermissionScreen.kt @@ -124,7 +124,7 @@ fun NaviBbpsPermissionScreen( // toast till we try updated compose lib. Toast.makeText( activity.applicationContext, - "Unexpected error in granting permission", + R.string.bbps_email_verification_failed, Toast.LENGTH_SHORT, ) .show() diff --git a/android/navi-bbps/src/main/kotlin/com/navi/bbps/network/service/NaviBbpsRetrofitService.kt b/android/navi-bbps/src/main/kotlin/com/navi/bbps/network/service/NaviBbpsRetrofitService.kt index 3073afef4e..a753cb9b99 100644 --- a/android/navi-bbps/src/main/kotlin/com/navi/bbps/network/service/NaviBbpsRetrofitService.kt +++ b/android/navi-bbps/src/main/kotlin/com/navi/bbps/network/service/NaviBbpsRetrofitService.kt @@ -10,8 +10,9 @@ package com.navi.bbps.network.service import com.google.common.net.HttpHeaders.ACCEPT_ENCODING import com.navi.bbps.common.model.network.BbpsABTestingResponse import com.navi.bbps.common.model.network.BbpsGenericResponse +import com.navi.bbps.common.model.network.BillDetectionRequest +import com.navi.bbps.common.model.network.BillDetectionResponse import com.navi.bbps.common.model.network.DetectedBillsResponse -import com.navi.bbps.common.model.network.FetchDetectedBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsRequest import com.navi.bbps.common.model.network.FetchMultipleBillsResponse import com.navi.bbps.common.model.network.RewardDetailsV2Response @@ -212,10 +213,15 @@ interface NaviBbpsRetrofitService { @Path("url", encoded = true) url: String, ): Response> - @POST("/billpay-gateway/$NAVI_BBPS_API_VERSION/billpay/detected-bills") - suspend fun fetchDetectedBills( + @POST("/billpay-gateway/$NAVI_BBPS_API_VERSION/billpay/bill-detection") + suspend fun billDetection( @Header(ACCEPT_ENCODING) acceptEncoding: String = GZIP, - @Body fetchDetectedBillsRequest: FetchDetectedBillsRequest, + @Body billDetectionRequest: BillDetectionRequest, + ): Response> + + @GET("/billpay-gateway/$NAVI_BBPS_API_VERSION/billpay/detected-bills") + suspend fun fetchDetectedBills( + @Header(ACCEPT_ENCODING) acceptEncoding: String = GZIP ): Response> @POST("/billpay-gateway/$NAVI_BBPS_API_VERSION/billpay/bill-details-bulk") diff --git a/android/navi-bbps/src/main/res/drawable/ic_bbps_email_ftu_icon.xml b/android/navi-bbps/src/main/res/drawable/ic_bbps_email_ftu_icon.xml new file mode 100644 index 0000000000..9b4e0b9230 --- /dev/null +++ b/android/navi-bbps/src/main/res/drawable/ic_bbps_email_ftu_icon.xml @@ -0,0 +1,98 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/navi-bbps/src/main/res/drawable/ic_bbps_email_icon.xml b/android/navi-bbps/src/main/res/drawable/ic_bbps_email_icon.xml new file mode 100644 index 0000000000..d42c417684 --- /dev/null +++ b/android/navi-bbps/src/main/res/drawable/ic_bbps_email_icon.xml @@ -0,0 +1,20 @@ + + + + diff --git a/android/navi-bbps/src/main/res/drawable/ic_bbps_google_logo.xml b/android/navi-bbps/src/main/res/drawable/ic_bbps_google_logo.xml new file mode 100644 index 0000000000..7d44e013b0 --- /dev/null +++ b/android/navi-bbps/src/main/res/drawable/ic_bbps_google_logo.xml @@ -0,0 +1,22 @@ + + + + + + + + + diff --git a/android/navi-bbps/src/main/res/drawable/ic_bbps_origin_add_remaining_bills.xml b/android/navi-bbps/src/main/res/drawable/ic_bbps_origin_add_remaining_bills.xml deleted file mode 100644 index f944179b55..0000000000 --- a/android/navi-bbps/src/main/res/drawable/ic_bbps_origin_add_remaining_bills.xml +++ /dev/null @@ -1,152 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/navi-bbps/src/main/res/drawable/ic_bbps_sms_icon.xml b/android/navi-bbps/src/main/res/drawable/ic_bbps_sms_icon.xml new file mode 100644 index 0000000000..7953243d3e --- /dev/null +++ b/android/navi-bbps/src/main/res/drawable/ic_bbps_sms_icon.xml @@ -0,0 +1,13 @@ + + + diff --git a/android/navi-bbps/src/main/res/values/strings.xml b/android/navi-bbps/src/main/res/values/strings.xml index c32ed9645e..cbf74ca3b7 100644 --- a/android/navi-bbps/src/main/res/values/strings.xml +++ b/android/navi-bbps/src/main/res/values/strings.xml @@ -320,9 +320,11 @@ Biller account is not responding Add found via SMS - Continue to add - Bills & recharges found + found via email + Add to my bills + New bills found (%s) We have found the following from your SMS + Select the bills & recharges you want to manage Adding bills & recharges %s added successfully Unable to find any bill & recharges @@ -357,6 +359,11 @@ Pay again Last payment on %s failed, know why? know why? + View all bills (%s) + Find bills from email + Sign in with Google + Email verification failed. Try again later! + We will notify you once we find any bills You currently have no due bills for this biller. Pay in advance Recharge diff --git a/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandlerTest.kt b/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandlerTest.kt new file mode 100644 index 0000000000..41366e351d --- /dev/null +++ b/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/utils/BbpsOriginSessionHandlerTest.kt @@ -0,0 +1,458 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.utils + +import com.navi.bbps.common.BbpsSharedPreferences +import com.navi.bbps.common.KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN +import com.navi.bbps.common.KEY_BBPS_ORIGIN_SMS_NUX_SEEN +import com.navi.bbps.common.KEY_BBPS_ORIGIN_WIDGET +import com.navi.bbps.common.NaviBbpsScreen +import com.navi.bbps.common.mapper.DetectedBillsMapper +import com.navi.bbps.common.model.network.DetectedBillItem +import com.navi.bbps.common.model.network.DetectedBillsResponse +import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.DetectedBillSource +import com.navi.bbps.common.model.view.DetectedBillStatus +import com.navi.bbps.feature.detectedbills.DetectedBillsRepository +import com.navi.common.network.models.RepoResult +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for BbpsOriginSessionHandler. + * + * This test class verifies the behavior of the BbpsOriginSessionHandler, including: + * - Retrieving origin session attributes based on different scenarios + * - Correct handling of NUX (New User Experience) flags + * - Proper widget status determination based on consent and available bills + * - Persistence of session data + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BbpsOriginSessionHandlerTest { + // Dependencies to be mocked + @MockK private lateinit var detectedBillsRepository: DetectedBillsRepository + @MockK private lateinit var bbpsSharedPreferences: BbpsSharedPreferences + @MockK private lateinit var detectedBillsMapper: DetectedBillsMapper + + // Class under test + private lateinit var handler: BbpsOriginSessionHandler + + /** + * Set up the test environment before each test. + * 1. Initialize mock annotations + * 2. Create the handler instance with test dependencies + * 3. Set up common mock behaviors + */ + @Before + fun setUp() { + // Initialize MockK annotations with relaxed behavior + MockKAnnotations.init(this, relaxed = true) + + // Mock NaviBbpsCommonUtils which is used in fetchAndUpdateOriginSession + mockkObject(NaviBbpsCommonUtils) + every { NaviBbpsCommonUtils.getBbpsMetricInfo(any(), any()) } returns + mockk(relaxed = true) + + // Initialize the handler with test dependencies + handler = + BbpsOriginSessionHandler( + detectedBillsRepository = detectedBillsRepository, + bbpsSharedPreferences = bbpsSharedPreferences, + detectedBillsMapper = detectedBillsMapper, + ) + } + + /** Helper method to create a sample detected bill entity for testing */ + private fun createSampleDetectedBillResponseItem(id: String = "bill_001"): DetectedBillItem { + return DetectedBillItem( + detectedBillId = id, + billerId = "biller_123", + billerName = "Test Biller", + billerLogo = "https://example.com/logo.png", + accountHolderName = "Test User", + primaryCustomerParamValue = "12345678", + customerParams = mapOf("param1" to "value1"), + billSource = "SMS", + categoryId = "utility", + categoryTitle = "Utility", + status = DetectedBillStatus.DETECTED_BILL_STATUS_PENDING.name, + detectedOn = "2023-10-01T12:00:00Z", + ) + } + + private fun createSampleDetectedBillEntity(id: String = "bill_001"): DetectedBillEntity { + return DetectedBillEntity( + detectedBillId = id, + billerId = "biller_123", + billerName = "Test Biller", + billerLogo = "https://example.com/logo.png", + accountHolderName = "Test User", + primaryCustomerParamValue = "12345678", + customerParams = mapOf("param1" to "value1"), + detectedBillSource = DetectedBillSource.SMS, + categoryId = "utility", + categoryTitle = "Utility", + detectedBillStatus = DetectedBillStatus.DETECTED_BILL_STATUS_PENDING, + isBillSeen = true, + ) + } + + /** + * Test case: When getOriginAttributes is called with email sub-experiment disabled and the + * current status is EMAIL_FTU Expected: Should return HIDDEN status + */ + @Test + fun `getOriginAttributes returns HIDDEN when EMAIL_FTU and email sub-experiment disabled`() = + runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + val mappedDetectedBillEntities = listOf(createSampleDetectedBillEntity()) + + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = false, + bills = emptyList(), + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsMapper.map(any(), any()) } returns mappedDetectedBillEntities + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns emptyList() + + // Mock handler to return EMAIL_FTU for internal status calculation + val originSessionAttributes = + OriginSessionAttributes( + originWidgetStatus = OriginWidgetStatus.EMAIL_FTU, + detectedBills = mappedDetectedBillEntities, + isOriginSmsNuxSeen = true, + isOriginEmailNuxSeen = false, + ) + + // Note: We need to handle the private method call indirectly by mocking repository + // responses + + // Act + val result = + handler.getOriginAttributes( + screen = screen, + isOriginEmailSubExperimentEnabled = false, + ) + + // Assert + assertEquals(OriginWidgetStatus.HIDDEN, result.originWidgetStatus) + } + + /** + * Test case: When setOriginNuxSeen is called with EMAIL source Expected: Should save + * EMAIL_NUX_SEEN flag as true in shared preferences + */ + @Test + fun `setOriginNuxSeen sets email NUX flag correctly`() { + // Arrange + val sharedPrefKeySlot = slot() + val sharedPrefValueSlot = slot() + + every { + bbpsSharedPreferences.saveBoolean( + capture(sharedPrefKeySlot), + capture(sharedPrefValueSlot), + ) + } returns Unit + + // Act + handler.setOriginNuxSeen(DetectedBillSource.EMAIL) + + // Assert + assertEquals(KEY_BBPS_ORIGIN_EMAIL_NUX_SEEN, sharedPrefKeySlot.captured) + assertTrue(sharedPrefValueSlot.captured) + } + + /** + * Test case: When setOriginNuxSeen is called with SMS source Expected: Should save SMS_NUX_SEEN + * flag as true in shared preferences + */ + @Test + fun `setOriginNuxSeen sets SMS NUX flag correctly`() { + // Arrange + val sharedPrefKeySlot = slot() + val sharedPrefValueSlot = slot() + + every { + bbpsSharedPreferences.saveBoolean( + capture(sharedPrefKeySlot), + capture(sharedPrefValueSlot), + ) + } returns Unit + + // Act + handler.setOriginNuxSeen(DetectedBillSource.SMS) + + // Assert + assertEquals(KEY_BBPS_ORIGIN_SMS_NUX_SEEN, sharedPrefKeySlot.captured) + assertTrue(sharedPrefValueSlot.captured) + } + + /** + * Test case: When setOriginNuxSeen is called with UNKNOWN source Expected: Should not save any + * flag in shared preferences + */ + @Test + fun `setOriginNuxSeen does nothing for UNKNOWN source`() { + // Act + handler.setOriginNuxSeen(DetectedBillSource.UNKNOWN) + + // Assert - verify no interaction with shared preferences + verify(exactly = 0) { bbpsSharedPreferences.saveBoolean(any(), any()) } + } + + /** + * Test case: When getOriginAttributes is called with SMS consent not provided Expected: Should + * return SMS_FTU status with empty bills list + */ + @Test + fun `getOriginAttributes returns SMS_FTU when SMS consent not provided`() = runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = false, + isEmailConsentProvided = false, + bills = emptyList(), + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsMapper.map(any(), any()) } returns emptyList() + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns emptyList() + + // Act + val result = + handler.getOriginAttributes(screen = screen, isOriginEmailSubExperimentEnabled = true) + + // Assert + assertEquals(OriginWidgetStatus.SMS_FTU, result.originWidgetStatus) + assertTrue(result.detectedBills.isEmpty()) + } + + /** + * Test case: When getOriginAttributes is called with SMS consent provided, no EMAIL consent, + * and bills available Expected: Should return SMS_RTU status with proper bills list + */ + @Test + fun `getOriginAttributes returns SMS_RTU when SMS consent provided with bills available`() = + runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + val billsList = listOf(createSampleDetectedBillResponseItem()) + val mappedDetectedBillEntities = listOf(createSampleDetectedBillEntity()) + + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = false, + bills = billsList, + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsMapper.map(any(), any()) } returns mappedDetectedBillEntities + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns emptyList() + + // Act + val result = + handler.getOriginAttributes( + screen = screen, + isOriginEmailSubExperimentEnabled = true, + ) + + // Assert + assertEquals(OriginWidgetStatus.SMS_RTU, result.originWidgetStatus) + assertEquals(billsList.size, result.detectedBills.size) + } + + /** + * Test case: When getOriginAttributes is called with SMS consent provided, no EMAIL consent, + * and no bills available Expected: Should return EMAIL_FTU status with empty bills list + */ + @Test + fun `getOriginAttributes returns EMAIL_FTU when SMS consent provided with no bills available`() = + runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = false, + bills = emptyList(), + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsMapper.map(any(), any()) } returns emptyList() + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns emptyList() + + // Act + val result = + handler.getOriginAttributes( + screen = screen, + isOriginEmailSubExperimentEnabled = true, + ) + + // Assert + assertEquals(OriginWidgetStatus.EMAIL_FTU, result.originWidgetStatus) + assertTrue(result.detectedBills.isEmpty()) + } + + /** + * Test case: When getOriginAttributes is called with both consents provided and bills available + * Expected: Should return EMAIL_RTU status with bills list + */ + @Test + fun `getOriginAttributes returns EMAIL_RTU when both consents provided with bills available`() = + runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + val billsList = listOf(createSampleDetectedBillResponseItem()) + val mappedDetectedBillEntities = listOf(createSampleDetectedBillEntity()) + + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = true, + bills = billsList, + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsMapper.map(any(), any()) } returns mappedDetectedBillEntities + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns emptyList() + + // Act + val result = + handler.getOriginAttributes( + screen = screen, + isOriginEmailSubExperimentEnabled = true, + ) + + // Assert + assertEquals(OriginWidgetStatus.EMAIL_RTU, result.originWidgetStatus) + assertEquals(billsList.size, result.detectedBills.size) + } + + /** + * Test case: When getOriginAttributes is called but repository returns an error Expected: + * Should return empty attributes with HIDDEN status + */ + @Test + fun `getOriginAttributes returns empty attributes when repository returns error`() = runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(errors = listOf(mockk())) + + // Act + val result = + handler.getOriginAttributes(screen = screen, isOriginEmailSubExperimentEnabled = true) + + // Assert + assertEquals(OriginWidgetStatus.HIDDEN, result.originWidgetStatus) + assertTrue(result.detectedBills.isEmpty()) + assertTrue(result.isOriginSmsNuxSeen) + assertTrue(result.isOriginEmailNuxSeen) + } + + /** + * Test case: When setOriginWidget is called with a specific status Expected: Should save that + * status in shared preferences + */ + @Test + fun `setOriginWidget saves status to shared preferences`() { + // Arrange + val status = OriginWidgetStatus.SMS_RTU + val sharedPrefKeySlot = slot() + val sharedPrefValueSlot = slot() + + every { + bbpsSharedPreferences.saveString( + capture(sharedPrefKeySlot), + capture(sharedPrefValueSlot), + ) + } returns Unit + + // Act + handler.setOriginWidget(status) + + // Assert + assertEquals(KEY_BBPS_ORIGIN_WIDGET, sharedPrefKeySlot.captured) + assertEquals(status.name, sharedPrefValueSlot.captured) + } + + /** + * Test case: When getOriginAttributes is called with SMS consent provided, email consent + * provided, but no bills available (which triggers the "else" branch) Expected: Should return + * HIDDEN status and use cached bills + */ + @Test + fun `getOriginAttributes returns HIDDEN status when both consents provided but no bills available`() = + runTest { + // Arrange + val screen = NaviBbpsScreen.NAVI_BBPS_BILL_CATEGORIES + val cachedBills = listOf(createSampleDetectedBillEntity("cached_bill_001")) + val mappedDetectedBillEntities = + listOf(createSampleDetectedBillEntity("mapped_bill_001")) + + // This response will trigger the 'else' branch (SMS consent provided, email consent + // provided, but no bills) + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = true, + bills = emptyList(), // No bills from network + ) + + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + coEvery { detectedBillsRepository.getCachedDetectedBills() } returns cachedBills + coEvery { detectedBillsMapper.map(any(), any()) } returns mappedDetectedBillEntities + + // Act + val result = + handler.getOriginAttributes( + screen = screen, + isOriginEmailSubExperimentEnabled = true, + ) + + // Assert + assertEquals(OriginWidgetStatus.HIDDEN, result.originWidgetStatus) + assertEquals(mappedDetectedBillEntities.size, result.detectedBills.size) + + // Verify that it called the mapper with both the response and cached bills + coVerify { + detectedBillsRepository.getCachedDetectedBills() + detectedBillsMapper.map(eq(detectedBillsResponse), any()) + detectedBillsRepository.saveDetectedBillsToLocalDb(eq(mappedDetectedBillEntities)) + } + } +} diff --git a/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandlerTest.kt b/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandlerTest.kt new file mode 100644 index 0000000000..1f90effa7b --- /dev/null +++ b/android/navi-bbps/src/test/kotlin/com/navi/bbps/common/viewmodel/OriginBillDetectionHandlerTest.kt @@ -0,0 +1,409 @@ +/* + * + * * Copyright © 2025 by Navi Technologies Limited + * * All rights reserved. Strictly confidential + * + */ + +package com.navi.bbps.common.viewmodel + +import com.navi.base.utils.NaviNetworkConnectivity +import com.navi.base.utils.ResourceProvider +import com.navi.bbps.common.model.config.NaviBbpsDefaultConfig +import com.navi.bbps.common.model.network.BillDetectionResponse +import com.navi.bbps.common.model.network.DetectedBillsResponse +import com.navi.bbps.common.model.view.DetectedBillEntity +import com.navi.bbps.common.model.view.FullScreenLoaderState +import com.navi.bbps.feature.detectedbills.DetectedBillsRepository +import com.navi.bbps.feature.mybills.MyBillsSyncJob +import com.navi.common.di.CoroutineDispatcherProvider +import com.navi.common.network.models.GenericErrorResponse +import com.navi.common.network.models.RepoResult +import io.mockk.MockKAnnotations +import io.mockk.coEvery +import io.mockk.impl.annotations.MockK +import io.mockk.mockk +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * Unit tests for OriginBillDetectionHandler. + * + * This test class verifies the behavior of the OriginBillDetectionHandler, including: + * - The startDetectingBills method with different scenarios + * - Various helper functions within the handler + */ +@OptIn(ExperimentalCoroutinesApi::class) +class OriginBillDetectionHandlerTest { + // Dependencies to be mocked + @MockK private lateinit var detectedBillsRepository: DetectedBillsRepository + @MockK private lateinit var resourceProvider: ResourceProvider + @MockK private lateinit var naviNetworkConnectivity: NaviNetworkConnectivity + @MockK private lateinit var myBillsSyncJob: MyBillsSyncJob + + // Class under test + private lateinit var handler: OriginBillDetectionHandler + + // Coroutine testing utilities + private val testScheduler = TestCoroutineScheduler() + private val testDispatcher = StandardTestDispatcher(scheduler = testScheduler) + private val testScope = TestScope(testDispatcher) + + /** + * Test implementation of CoroutineDispatcherProvider that uses a single test dispatcher for all + * coroutine contexts to make testing deterministic. + */ + class TestCoroutineDispatcherProviderImpl(private val testDispatcher: TestDispatcher) : + CoroutineDispatcherProvider { + override val default: CoroutineDispatcher + get() = testDispatcher + + override val main: CoroutineDispatcher + get() = testDispatcher + + override val io: CoroutineDispatcher + get() = testDispatcher + } + + /** + * Set up the test environment before each test. + * 1. Configure Dispatchers.Main to use our test dispatcher + * 2. Initialize mock annotations + * 3. Create the handler instance with test dependencies + */ + @OptIn(ExperimentalCoroutinesApi::class) + @Before + fun setUp() { + // Configure the main dispatcher for testing + Dispatchers.setMain(testDispatcher) + + // Initialize MockK annotations with relaxed behavior + MockKAnnotations.init(this, relaxed = true) + + // Initialize the handler with test dependencies + handler = + OriginBillDetectionHandler( + coroutineDispatcherProvider = TestCoroutineDispatcherProviderImpl(testDispatcher), + detectedBillsRepository = detectedBillsRepository, + resourceProvider = resourceProvider, + naviNetworkConnectivity = naviNetworkConnectivity, + myBillsSyncJob = myBillsSyncJob, + ) + } + + /** + * Test case: When bills are available and user has provided consent Expected: + * onShowDetectedBillsBottomSheet should be called + */ + @Test + fun `startDetectingBills calls onShowDetectedBillsBottomSheet when consent and bills available`() = + runTest { + // Arrange + val naviBbpsBaseVM = mockk(relaxed = true) + val config = mockk(relaxed = true) + val testCallbacks = createTestCallbacks() + + // Mock the repository response with consent and bills + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = true, + bills = listOf(mockk(relaxed = true)), + ) + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(data = detectedBillsResponse) + + // Act + handler.startDetectingBills( + naviBbpsBaseVM = naviBbpsBaseVM, + coroutineScope = testScope, + permission = "SMS", + naviBbpsDefaultConfig = config, + updateOpenBottomSheet = testCallbacks.updateOpenBottomSheet, + updateBottomSheetCancellable = testCallbacks.updateBottomSheetCancellable, + onShowDetectedBillsBottomSheet = testCallbacks.onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = testCallbacks.onNoBillsDetectedBottomSheet, + onErrorResponse = testCallbacks.onErrorResponse, + onNotifyLaterBottomSheet = testCallbacks.onNotifyLaterBottomSheet, + ) + + // Assert + with(testCallbacks.flags) { + assertTrue("Should call onShowDetectedBillsBottomSheet", showDetectedBillsCalled) + assertFalse("Should not call onNoBillsDetectedBottomSheet", noBillsDetectedCalled) + assertFalse("Should not call onErrorResponse", errorResponseCalled) + assertFalse("Should not call onNotifyLaterBottomSheet", notifyLaterCalled) + } + } + + /** + * Test case: When user has provided consent but no bills are available Expected: + * onNoBillsDetectedBottomSheet should be called + */ + @Test + fun `startDetectingBills calls onNoBillsDetectedBottomSheet when no bills available`() = + runTest { + // Arrange + val naviBbpsBaseVM = mockk(relaxed = true) + val config = mockk(relaxed = true) + val testCallbacks = createTestCallbacks() + + // Mock the repository response with consent but no bills + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = true, + bills = emptyList(), + ) + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + + // Act + handler.startDetectingBills( + naviBbpsBaseVM = naviBbpsBaseVM, + coroutineScope = testScope, + permission = "SMS", + naviBbpsDefaultConfig = config, + updateOpenBottomSheet = testCallbacks.updateOpenBottomSheet, + updateBottomSheetCancellable = testCallbacks.updateBottomSheetCancellable, + onShowDetectedBillsBottomSheet = testCallbacks.onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = testCallbacks.onNoBillsDetectedBottomSheet, + onErrorResponse = testCallbacks.onErrorResponse, + onNotifyLaterBottomSheet = testCallbacks.onNotifyLaterBottomSheet, + ) + + // Assert + with(testCallbacks.flags) { + assertFalse( + "Should not call onShowDetectedBillsBottomSheet", + showDetectedBillsCalled, + ) + assertTrue("Should call onNoBillsDetectedBottomSheet", noBillsDetectedCalled) + assertFalse("Should not call onErrorResponse", errorResponseCalled) + assertFalse("Should not call onNotifyLaterBottomSheet", notifyLaterCalled) + } + } + + /** + * Test case: When email consent is not provided but SMS consent is available with empty bills + * Expected: onNotifyLaterBottomSheet should be called + */ + @Test + fun `startDetectingBills calls onNotifyLaterBottomSheet when email bill detection with empty bills list`() = + runTest { + // Arrange + val naviBbpsBaseVM = mockk(relaxed = true) + val config = mockk(relaxed = true) + val testCallbacks = createTestCallbacks() + + // Mock responses + val detectedBillsResponse = + DetectedBillsResponse( + isSmsConsentProvided = true, + isEmailConsentProvided = false, + bills = listOf(), + ) + + // Setup repository mocks + coEvery { detectedBillsRepository.billDetection(any(), any()) } returns + RepoResult(BillDetectionResponse(status = "SUCCESS")) + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns + RepoResult(detectedBillsResponse) + + // Act + handler.startDetectingBills( + naviBbpsBaseVM = naviBbpsBaseVM, + coroutineScope = testScope, + permission = "EMAIL", // Using EMAIL permission + naviBbpsDefaultConfig = config, + updateOpenBottomSheet = testCallbacks.updateOpenBottomSheet, + updateBottomSheetCancellable = testCallbacks.updateBottomSheetCancellable, + onShowDetectedBillsBottomSheet = testCallbacks.onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = testCallbacks.onNoBillsDetectedBottomSheet, + onErrorResponse = testCallbacks.onErrorResponse, + onNotifyLaterBottomSheet = testCallbacks.onNotifyLaterBottomSheet, + ) + + // Assert + with(testCallbacks.flags) { + assertFalse( + "Should not call onShowDetectedBillsBottomSheet", + showDetectedBillsCalled, + ) + assertFalse("Should not call onNoBillsDetectedBottomSheet", noBillsDetectedCalled) + assertFalse("Should not call onErrorResponse", errorResponseCalled) + assertTrue("Should call onNotifyLaterBottomSheet", notifyLaterCalled) + } + } + + /** + * Test case: When repository returns an error response Expected: onErrorResponse should be + * called + */ + @Test + fun `startDetectingBills calls onErrorResponse when fetchInitialDetectedBills returns error`() = + runTest { + // Arrange + val naviBbpsBaseVM = mockk(relaxed = true) + val config = mockk(relaxed = true) + val testCallbacks = createTestCallbacks() + + // Mock error response + val errorResponse = + RepoResult( + errors = + listOf( + GenericErrorResponse( + code = "error_code", + title = "error_title", + message = "error_message", + ) + ) + ) + coEvery { detectedBillsRepository.fetchDetectedBills(any()) } returns errorResponse + + // Act + handler.startDetectingBills( + naviBbpsBaseVM = naviBbpsBaseVM, + coroutineScope = testScope, + permission = "SMS", + naviBbpsDefaultConfig = config, + updateOpenBottomSheet = testCallbacks.updateOpenBottomSheet, + updateBottomSheetCancellable = testCallbacks.updateBottomSheetCancellable, + onShowDetectedBillsBottomSheet = testCallbacks.onShowDetectedBillsBottomSheet, + onNoBillsDetectedBottomSheet = testCallbacks.onNoBillsDetectedBottomSheet, + onErrorResponse = testCallbacks.onErrorResponse, + onNotifyLaterBottomSheet = testCallbacks.onNotifyLaterBottomSheet, + ) + + // Assert + with(testCallbacks.flags) { + assertFalse( + "Should not call onShowDetectedBillsBottomSheet", + showDetectedBillsCalled, + ) + assertFalse("Should not call onNoBillsDetectedBottomSheet", noBillsDetectedCalled) + assertTrue("Should call onErrorResponse", errorResponseCalled) + assertFalse("Should not call onNotifyLaterBottomSheet", notifyLaterCalled) + } + } + + /** + * Test case: Verify resetLoadingIndicatorProgress resets progress to zero Expected: + * loadingIndicatorProgress should be set to 0f + */ + @Test + fun `resetLoadingIndicatorProgress sets progress to zero`() = runTest { + // Act + handler.resetLoadingIndicatorProgress() + + // Assert + assertEquals(0f, handler.loadingIndicatorProgress.value) + } + + /** + * Test case: Verify onFullScreenLoaderDismissed clears loader state Expected: + * fullScreenLoaderState should be null Note: isCallInterrupted is private and cannot be tested + * directly + */ + @Test + fun `onFullScreenLoaderDismissed clears loader state and interrupts call`() = runTest { + // Act + handler.onFullScreenLoaderDismissed() + + // Assert + assertNull(handler.fullScreenLoaderState.value) + // isCallInterrupted is private, so we can't check directly + } + + /** + * Test case: Verify updateFullScreenLoaderState updates loader state correctly Expected: + * fullScreenLoaderState should be updated with the provided state Note: Using reflection to + * access private method + */ + @Test + fun `updateFullScreenLoaderState updates loader state`() = runTest { + // Arrange + val state = + FullScreenLoaderState( + isVisible = true, + lottieFileName = "test", + message = "msg", + showLottieInfiniteTimes = false, + ) + + // Act - using reflection to access private method + val updateStateMethod = + handler.javaClass.getDeclaredMethod( + "updateFullScreenLoaderState", + FullScreenLoaderState::class.java, + ) + updateStateMethod.isAccessible = true + updateStateMethod.invoke(handler, state) + + // Assert + assertEquals(state, handler.fullScreenLoaderState.value) + } + + /** + * Clean up after each test to avoid affecting other tests. Reset the main dispatcher to its + * original state. + */ + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** + * Creates common test callbacks used in the startDetectingBills tests. Returns a data class + * containing all necessary callbacks and flag variables. + */ + private fun createTestCallbacks(): TestCallbacks { + val flags = CallbackFlags() + return TestCallbacks( + updateOpenBottomSheet = { _: Boolean -> }, + updateBottomSheetCancellable = { _: Boolean -> }, + onShowDetectedBillsBottomSheet = { _: List -> + flags.showDetectedBillsCalled = true + }, + onNoBillsDetectedBottomSheet = { flags.noBillsDetectedCalled = true }, + onErrorResponse = { flags.errorResponseCalled = true }, + onNotifyLaterBottomSheet = { flags.notifyLaterCalled = true }, + flags = flags, + ) + } + + /** Data class to hold the test callback functions */ + private data class TestCallbacks( + val updateOpenBottomSheet: suspend (Boolean) -> Unit, + val updateBottomSheetCancellable: suspend (Boolean) -> Unit, + val onShowDetectedBillsBottomSheet: suspend (List) -> Unit, + val onNoBillsDetectedBottomSheet: suspend () -> Unit, + val onErrorResponse: suspend () -> Unit, + val onNotifyLaterBottomSheet: suspend () -> Unit, + val flags: CallbackFlags, + ) + + /** Data class to track which callbacks were invoked */ + private data class CallbackFlags( + var showDetectedBillsCalled: Boolean = false, + var noBillsDetectedCalled: Boolean = false, + var errorResponseCalled: Boolean = false, + var notifyLaterCalled: Boolean = false, + ) +}