diff --git a/app/apollo/apollo-core/src/commonMain/kotlin/com/hedvig/android/apollo/ApolloCallExt.kt b/app/apollo/apollo-core/src/commonMain/kotlin/com/hedvig/android/apollo/ApolloCallExt.kt index 8e3186f529..4520eacc18 100644 --- a/app/apollo/apollo-core/src/commonMain/kotlin/com/hedvig/android/apollo/ApolloCallExt.kt +++ b/app/apollo/apollo-core/src/commonMain/kotlin/com/hedvig/android/apollo/ApolloCallExt.kt @@ -20,6 +20,7 @@ import com.hedvig.android.apollo.ApolloOperationError.OperationError import com.hedvig.android.apollo.ApolloOperationError.OperationException import com.hedvig.android.apollo.parseResponse import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.logger.logcat import kotlin.jvm.JvmInline import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow @@ -175,10 +176,21 @@ private fun iorFromErrorsAndData( data: D?, ): Ior, D> { return when { - errors != null && data != null -> Ior.Both(errors, data) - errors != null -> Ior.Left(errors) - data != null -> Ior.Right(data) - else -> error("Non compliant server") + errors != null && data != null -> { + Ior.Both(errors, data) + } + + errors != null -> { + Ior.Left(errors) + } + + data != null -> { + Ior.Right(data) + } + + else -> { + error("Non compliant server") + } } } diff --git a/app/feature/feature-payments/src/main/graphql/FragmentMemberPaymentMethod.graphql b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/graphql/FragmentMemberPaymentMethod.graphql similarity index 100% rename from app/feature/feature-payments/src/main/graphql/FragmentMemberPaymentMethod.graphql rename to app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/graphql/FragmentMemberPaymentMethod.graphql diff --git a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls index e0976850c6..3361076d26 100644 --- a/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls +++ b/app/apollo/apollo-octopus-public/src/commonMain/graphql/com/hedvig/android/apollo/octopus/schema.graphqls @@ -1,3 +1,228 @@ +""" +A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. +""" +type __Schema { + description: String + """ + A list of all types supported by this server. + """ + types: [__Type!]! + """ + The type that query operations will be rooted at. + """ + queryType: __Type! + """ + If this server supports mutation, the type that mutation operations will be rooted at. + """ + mutationType: __Type + """ + If this server support subscription, the type that subscription operations will be rooted at. + """ + subscriptionType: __Type + """ + A list of all directives supported by this server. + """ + directives: [__Directive!]! +} +""" +The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. +Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. +""" +type __Type { + kind: __TypeKind! + name: String + description: String + fields(includeDeprecated: Boolean = false): [__Field!] + interfaces: [__Type!] + possibleTypes: [__Type!] + enumValues(includeDeprecated: Boolean = false): [__EnumValue!] + inputFields(includeDeprecated: Boolean = false): [__InputValue!] + ofType: __Type + specifiedByURL: String +} +""" +An enum describing what kind of type a given `__Type` is. +""" +enum __TypeKind { + """ + Indicates this type is a scalar. + """ + SCALAR + """ + Indicates this type is an object. `fields` and `interfaces` are valid fields. + """ + OBJECT + """ + Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields. + """ + INTERFACE + """ + Indicates this type is a union. `possibleTypes` is a valid field. + """ + UNION + """ + Indicates this type is an enum. `enumValues` is a valid field. + """ + ENUM + """ + Indicates this type is an input object. `inputFields` is a valid field. + """ + INPUT_OBJECT + """ + Indicates this type is a list. `ofType` is a valid field. + """ + LIST + """ + Indicates this type is a non-null. `ofType` is a valid field. + """ + NON_NULL +} +""" +Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type. +""" +type __Field { + name: String! + description: String + args(includeDeprecated: Boolean = false): [__InputValue!]! + type: __Type! + isDeprecated: Boolean! + deprecationReason: String +} +""" +Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value. +""" +type __InputValue { + name: String! + description: String + type: __Type! + """ + A GraphQL-formatted string representing the default value for this input value. + """ + defaultValue: String + isDeprecated: Boolean! + deprecationReason: String +} +""" +One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. +""" +type __EnumValue { + name: String! + description: String + isDeprecated: Boolean! + deprecationReason: String +} +""" +A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. +In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor. +""" +type __Directive { + name: String! + description: String + locations: [__DirectiveLocation!]! + args(includeDeprecated: Boolean = false): [__InputValue!]! + isRepeatable: Boolean! +} +""" +A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. +""" +enum __DirectiveLocation { + """ + Location adjacent to a query operation. + """ + QUERY + """ + Location adjacent to a mutation operation. + """ + MUTATION + """ + Location adjacent to a subscription operation. + """ + SUBSCRIPTION + """ + Location adjacent to a field. + """ + FIELD + """ + Location adjacent to a fragment definition. + """ + FRAGMENT_DEFINITION + """ + Location adjacent to a fragment spread. + """ + FRAGMENT_SPREAD + """ + Location adjacent to an inline fragment. + """ + INLINE_FRAGMENT + """ + Location adjacent to a variable definition. + """ + VARIABLE_DEFINITION + """ + Location adjacent to a schema definition. + """ + SCHEMA + """ + Location adjacent to a scalar definition. + """ + SCALAR + """ + Location adjacent to an object type definition. + """ + OBJECT + """ + Location adjacent to a field definition. + """ + FIELD_DEFINITION + """ + Location adjacent to an argument definition. + """ + ARGUMENT_DEFINITION + """ + Location adjacent to an interface definition. + """ + INTERFACE + """ + Location adjacent to a union definition. + """ + UNION + """ + Location adjacent to an enum definition. + """ + ENUM + """ + Location adjacent to an enum value definition. + """ + ENUM_VALUE + """ + Location adjacent to an input object type definition. + """ + INPUT_OBJECT + """ + Location adjacent to an input object field definition. + """ + INPUT_FIELD_DEFINITION +} +""" +The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. +""" +scalar Int +""" +The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). +""" +scalar Float +""" +The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. +""" +scalar String +""" +The `Boolean` scalar type represents `true` or `false`. +""" +scalar Boolean +""" +The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. +""" +scalar ID type ActiveAddon { id: ID! """ @@ -16,14 +241,6 @@ type ActiveAddon { displayDescription: String } """ -The `ID` scalar type represents a unique identifier, often used to refetch an object or as key for a cache. The ID type appears in a JSON response as a String; however, it is not intended to be human-readable. When expected as an input type, any string (such as `"4"`) or integer (such as `4`) input value will be accepted as an ID. -""" -scalar ID -""" -The `String` scalar type represents textual data, represented as UTF-8 character sequences. The String type is most often used by GraphQL to represent free-form human-readable text. -""" -scalar String -""" An addon is an additional insurance that can be added to a contract. It is usually a separate insurance. """ type Addon { @@ -96,10 +313,6 @@ type AddonComparisonCell { """ coverageText: String } -""" -The `Boolean` scalar type represents `true` or `false`. -""" -scalar Boolean type AddonComparisonRow { """ Peril title @@ -410,10 +623,6 @@ type Agreement { """ addons: [Addon!]! } -""" -The `Int` scalar type represents non-fractional signed whole numeric values. Int can represent values between -(2^31) and 2^31 - 1. -""" -scalar Int enum AgreementCreationCause { """ No creation cause is known - this is due to the agreement being older than when we started tracking @@ -984,10 +1193,6 @@ type ClaimIntent { """ createdClaim: Claim } -""" -The `Float` scalar type represents signed double-precision fractional values as specified by [IEEE 754](https://en.wikipedia.org/wiki/IEEE_floating_point). -""" -scalar Float input ClaimIntentFormFieldSearchInput { stepId: ID! fieldId: ID! @@ -2656,7 +2861,11 @@ type Member { Id of the latest charge if it failed and self manual charge allowed. """ missedChargeIdToChargeManually: UUID - paymentMethods: MemberPaymentMethods! + """ + The member's payment methods: what they can be charged with, what they are paid out to, + and which connection is still missing. + """ + paymentMethods("Rollout gate for payment providers, not a frozen contract per version.\n\n- `1`: Trustly, Nordea, Invoice — stays as it is.\n- `2`: `1` plus what is switched on, today Swish — can change between releases.\n\nAsk for `2` only once the client can render everything it may return." version: Int! = 1): MemberPaymentMethods! conversations: [Conversation!]! legacyConversation: Conversation """ @@ -3167,9 +3376,9 @@ type MemberPaymentMethods { """ Indicates which payment connection the member is missing, if any. The rule is: - If member has only QASA-paid agreements, the payin connection is irrelevant and only payout is checked. - Returns PAYOUT if no default payout method exists, else null. + Returns PAYOUT if no default payout method exists, else null. - Otherwise (any non-QASA agreement, e.g. member-paid), payin takes priority: - Returns PAYIN if no default payin method exists, else PAYOUT if no default payout method exists, else null. + Returns PAYIN if no default payin method exists, else PAYOUT if no default payout method exists, else null. - If member has no ongoing (active or future) agreements, returns null. """ missingConnection: MissingPaymentConnection @@ -3756,6 +3965,10 @@ type Mutation { Setup Swish payin method for the member. Requires member consent in Swish app. """ paymentMethodSetupSwishPayin(input: PaymentMethodSetupSwishInput!): PaymentMethodSetupOutput! + """ + Revoke and disconnect an ACTIVE payment method + """ + paymentMethodRemoveMethod(provider: MemberPaymentProvider!): UserError """ A member can have multiple ACTIVE payment methods where one of those is default. This mutation changes the members default payment method for charging to any of his/hers other active payment methods. @@ -4136,6 +4349,16 @@ enum PaymentMethodSetupStatus { """ FAILED } +type PaymentMethodSetupStatusOutput { + """ + The status of the setup process. If FAILED the reason for failure can be found in the `error` field. + """ + status: PaymentMethodSetupStatus! + """ + An error message. Only sat if the setup process failed. + """ + error: UserError +} input PaymentMethodSetupSwishInput { """ The Swish mobile number to use for payout or payin. @@ -4809,6 +5032,10 @@ type PuppyGuideStoryMutationOutput { success: Boolean! } type Query { + """ + Returns the status of a setup method order. Returns null if no status found + """ + paymentMethodSetupStatus(orderId: ID!): PaymentMethodSetupStatusOutput """ Return a conversation for a given ID. Note that this can return null if `Mutation.conversationStart` has not been called with this ID. @@ -5744,220 +5971,14 @@ type ValueString { } union ValueType = ValueString|ValueInteger|ValueBoolean|ValueDate|ValueArray """ -A GraphQL Schema defines the capabilities of a GraphQL server. It exposes all available types and directives on the server, as well as the entry points for query, mutation, and subscription operations. -""" -type __Schema { - description: String - """ - A list of all types supported by this server. - """ - types: [__Type!]! - """ - The type that query operations will be rooted at. - """ - queryType: __Type! - """ - If this server supports mutation, the type that mutation operations will be rooted at. - """ - mutationType: __Type - """ - If this server support subscription, the type that subscription operations will be rooted at. - """ - subscriptionType: __Type - """ - A list of all directives supported by this server. - """ - directives: [__Directive!]! -} -""" -The fundamental unit of any GraphQL Schema is the type. There are many kinds of types in GraphQL as represented by the `__TypeKind` enum. -Depending on the kind of a type, certain fields describe information about that type. Scalar types provide no information beyond a name, description and optional `specifiedByURL`, while Enum types provide their values. Object and Interface types provide the fields they describe. Abstract types, Union and Interface, provide the Object types possible at runtime. List and NonNull types compose other types. -""" -type __Type { - kind: __TypeKind! - name: String - description: String - specifiedByURL: String - fields(includeDeprecated: Boolean = false): [__Field!] - interfaces: [__Type!] - possibleTypes: [__Type!] - enumValues(includeDeprecated: Boolean = false): [__EnumValue!] - inputFields(includeDeprecated: Boolean = false): [__InputValue!] - ofType: __Type -} -""" -An enum describing what kind of type a given `__Type` is. -""" -enum __TypeKind { - """ - Indicates this type is a scalar. - """ - SCALAR - """ - Indicates this type is an object. `fields` and `interfaces` are valid fields. - """ - OBJECT - """ - Indicates this type is an interface. `fields`, `interfaces`, and `possibleTypes` are valid fields. - """ - INTERFACE - """ - Indicates this type is a union. `possibleTypes` is a valid field. - """ - UNION - """ - Indicates this type is an enum. `enumValues` is a valid field. - """ - ENUM - """ - Indicates this type is an input object. `inputFields` is a valid field. - """ - INPUT_OBJECT - """ - Indicates this type is a list. `ofType` is a valid field. - """ - LIST - """ - Indicates this type is a non-null. `ofType` is a valid field. - """ - NON_NULL -} -""" -Object and Interface types are described by a list of Fields, each of which has a name, potentially a list of arguments, and a return type. -""" -type __Field { - name: String! - description: String - args(includeDeprecated: Boolean = false): [__InputValue!]! - type: __Type! - isDeprecated: Boolean! - deprecationReason: String -} -""" -Arguments provided to Fields or Directives and the input fields of an InputObject are represented as Input Values which describe their type and optionally a default value. -""" -type __InputValue { - name: String! - description: String - type: __Type! - """ - A GraphQL-formatted string representing the default value for this input value. - """ - defaultValue: String - isDeprecated: Boolean! - deprecationReason: String -} -""" -One possible value for a given Enum. Enum values are unique values, not a placeholder for a string or numeric value. However an Enum value is returned in a JSON response as a string. -""" -type __EnumValue { - name: String! - description: String - isDeprecated: Boolean! - deprecationReason: String -} -""" -A Directive provides a way to describe alternate runtime execution and type validation behavior in a GraphQL document. -In some cases, you need to provide options to alter GraphQL's execution behavior in ways field arguments will not suffice, such as conditionally including or skipping a field. Directives provide this by describing additional information to the executor. -""" -type __Directive { - name: String! - description: String - isRepeatable: Boolean! - locations: [__DirectiveLocation!]! - args(includeDeprecated: Boolean = false): [__InputValue!]! -} -""" -A Directive can be adjacent to many parts of the GraphQL language, a __DirectiveLocation describes one such possible adjacencies. +Directs the executor to skip this field or fragment when the `if` argument is true. """ -enum __DirectiveLocation { - """ - Location adjacent to a query operation. - """ - QUERY - """ - Location adjacent to a mutation operation. - """ - MUTATION - """ - Location adjacent to a subscription operation. - """ - SUBSCRIPTION - """ - Location adjacent to a field. - """ - FIELD - """ - Location adjacent to a fragment definition. - """ - FRAGMENT_DEFINITION - """ - Location adjacent to a fragment spread. - """ - FRAGMENT_SPREAD - """ - Location adjacent to an inline fragment. - """ - INLINE_FRAGMENT - """ - Location adjacent to a variable definition. - """ - VARIABLE_DEFINITION - """ - Location adjacent to a schema definition. - """ - SCHEMA - """ - Location adjacent to a scalar definition. - """ - SCALAR - """ - Location adjacent to an object type definition. - """ - OBJECT - """ - Location adjacent to a field definition. - """ - FIELD_DEFINITION - """ - Location adjacent to an argument definition. - """ - ARGUMENT_DEFINITION - """ - Location adjacent to an interface definition. - """ - INTERFACE - """ - Location adjacent to a union definition. - """ - UNION - """ - Location adjacent to an enum definition. - """ - ENUM - """ - Location adjacent to an enum value definition. - """ - ENUM_VALUE - """ - Location adjacent to an input object type definition. - """ - INPUT_OBJECT - """ - Location adjacent to an input object field definition. - """ - INPUT_FIELD_DEFINITION -} -directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT +directive @skip("Skipped when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ Directs the executor to include this field or fragment only when the `if` argument is true. """ directive @include("Included when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT """ -Directs the executor to skip this field or fragment when the `if` argument is true. -""" -directive @skip("Skipped when true." if: Boolean!) on FIELD | FRAGMENT_SPREAD | INLINE_FRAGMENT -""" Marks an element of a GraphQL schema as no longer supported. """ directive @deprecated("Explains why this element was deprecated, usually also including a suggestion for how to access supported similar data. Formatted using the Markdown syntax, as specified by [CommonMark](https://commonmark.org/)." reason: String = "No longer supported") on FIELD_DEFINITION | ARGUMENT_DEFINITION | INPUT_FIELD_DEFINITION | ENUM_VALUE @@ -5965,6 +5986,7 @@ directive @deprecated("Explains why this element was deprecated, usually also in Exposes a URL that specifies the behavior of this scalar. """ directive @specifiedBy("The URL that specifies the behavior of this scalar." url: String!) on SCALAR +directive @defer(label: String, if: Boolean! = true) on FRAGMENT_SPREAD | INLINE_FRAGMENT schema { query: Query mutation: Mutation diff --git a/app/app/build.gradle.kts b/app/app/build.gradle.kts index 59f36d38bd..0d77663793 100644 --- a/app/app/build.gradle.kts +++ b/app/app/build.gradle.kts @@ -214,6 +214,8 @@ dependencies { implementation(projects.featureOnboarding) implementation(projects.featureRemoveAddons) + implementation(projects.featurePayinAccount) + implementation(projects.featurePayinAccountNavigation) implementation(projects.featurePayoutAccount) implementation(projects.featurePayments) implementation(projects.featurePaymentsNavigation) diff --git a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt index db53638902..08cd31cd4e 100644 --- a/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt +++ b/app/app/src/main/kotlin/com/hedvig/android/app/navigation/HedvigEntryProvider.kt @@ -52,6 +52,11 @@ import com.hedvig.android.feature.movingflow.SelectContractForMovingKey import com.hedvig.android.feature.movingflow.movingFlowEntries import com.hedvig.android.feature.onboarding.data.ResetOnboardingSeenUseCase import com.hedvig.android.feature.onboarding.navigation.onboardingEntries +import com.hedvig.android.feature.payin.account.navigation.PayinAccountKey +import com.hedvig.android.feature.payin.account.navigation.PayinMethodDetailsKey +import com.hedvig.android.feature.payin.account.navigation.SelectPayinMethodKey +import com.hedvig.android.feature.payin.account.navigation.SelectPrimaryPayinMethodKey +import com.hedvig.android.feature.payin.account.navigation.payinAccountEntries import com.hedvig.android.feature.payments.navigation.paymentsEntries import com.hedvig.android.feature.payoutaccount.navigation.PayoutAccountKey import com.hedvig.android.feature.payoutaccount.navigation.payoutAccountEntries @@ -105,6 +110,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( ) { val shouldShowRequestPermissionRationale: (String) -> Boolean = androidAppHost::shouldShowPermissionRationale val navigateToConnectPayment: () -> Unit = { backstack.add(TrustlyKey) } + val navigateToPayinAccount: () -> Unit = { backstack.add(PayinAccountKey) } val navigateToPayoutAccount: () -> Unit = { backstack.add(PayoutAccountKey) } val navigateToInbox: () -> Unit = { backstack.add(InboxKey) } val navigateToNewConversation: () -> Unit = { backstack.add(ChatKey(Uuid.randomUUID().toString())) } @@ -136,7 +142,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( navigateToNewConversation = navigateToNewConversation, navigateToConversation = navigateToConversation, navigateToInbox = navigateToInbox, - navigateToConnectPayment = navigateToConnectPayment, + navigateToPayinAccount = navigateToPayinAccount, navigateToPayoutAccount = navigateToPayoutAccount, navigateToTravelCertificate = navigateToTravelCertificate, navigateToAddonPurchaseFlow = navigateToAddonPurchaseFlow, @@ -158,7 +164,10 @@ internal fun EntryProviderScope.hedvigEntryProvider( addPaymentsEntries( backstack = backstack, globalSnackBarState = globalSnackBarState, + hedvigBuildConstants = hedvigBuildConstants, + openUrl = openUrl, navigateToConnectPayment = navigateToConnectPayment, + navigateToPayinAccount = navigateToPayinAccount, navigateToPayoutAccount = navigateToPayoutAccount, navigateToNewConversation = navigateToNewConversation, ) @@ -169,7 +178,7 @@ internal fun EntryProviderScope.hedvigEntryProvider( languageService = languageService, externalNavigator = externalNavigator, openUrl = openUrl, - navigateToConnectPayment = navigateToConnectPayment, + navigateToPayinAccount = navigateToPayinAccount, navigateToPayoutAccount = navigateToPayoutAccount, navigateToNewConversation = navigateToNewConversation, onResetOnboardingForDebug = { @@ -243,7 +252,7 @@ private fun EntryProviderScope.addHomeEntries( navigateToNewConversation: () -> Unit, navigateToConversation: (String) -> Unit, navigateToInbox: () -> Unit, - navigateToConnectPayment: () -> Unit, + navigateToPayinAccount: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToTravelCertificate: () -> Unit, navigateToAddonPurchaseFlow: (List) -> Unit, @@ -267,7 +276,7 @@ private fun EntryProviderScope.addHomeEntries( onNavigateToInbox = navigateToInbox, onNavigateToNewConversation = navigateToNewConversation, navigateToClaimDetails = { claimId -> backstack.add(ClaimDetailsKey(claimId)) }, - navigateToConnectPayment = navigateToConnectPayment, + navigateToConnectPayment = navigateToPayinAccount, navigateToConnectPayout = navigateToPayoutAccount, navigateToContactInfo = { backstack.add(ContactInfoKey) }, navigateToMissingInfo = { contractId: String, type: CoInsuredFlowType -> @@ -458,20 +467,37 @@ private fun EntryProviderScope.addInsuranceEntries( private fun EntryProviderScope.addPaymentsEntries( backstack: BackstackController, globalSnackBarState: GlobalSnackBarState, + hedvigBuildConstants: HedvigBuildConstants, + openUrl: (String) -> Unit, navigateToConnectPayment: () -> Unit, + navigateToPayinAccount: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, ) { paymentsEntries( backstack = backstack, navigateToConnectPayment = navigateToConnectPayment, + navigateToPayinAccount = navigateToPayinAccount, + navigateToPayinMethodDetails = { method -> backstack.add(PayinMethodDetailsKey(method)) }, navigateToPayoutAccount = navigateToPayoutAccount, + navigateToSelectPayinMethod = { availableProviders, currentProviders -> + backstack.add(SelectPayinMethodKey(availableProviders, currentProviders)) + }, + navigateToSelectPrimaryPayinMethod = { currentMethods -> + backstack.add(SelectPrimaryPayinMethodKey(currentMethods)) + }, openConversation = navigateToNewConversation, ) payoutAccountEntries( + backstack = backstack, + navigateToConnectPayment = navigateToConnectPayment, + ) + payinAccountEntries( backstack = backstack, globalSnackBarState = globalSnackBarState, + hedvigBuildConstants = hedvigBuildConstants, navigateToConnectPayment = navigateToConnectPayment, + openUrl = openUrl, ) connectPaymentEntries(backstack = backstack) } @@ -483,7 +509,7 @@ private fun EntryProviderScope.addProfileEntries( languageService: LanguageService, externalNavigator: ExternalNavigator, openUrl: (String) -> Unit, - navigateToConnectPayment: () -> Unit, + navigateToPayinAccount: () -> Unit, navigateToPayoutAccount: () -> Unit, navigateToNewConversation: () -> Unit, onResetOnboardingForDebug: () -> Unit, @@ -501,7 +527,7 @@ private fun EntryProviderScope.addProfileEntries( globalSnackBarState = globalSnackBarState, backstack = backstack, hedvigBuildConstants = hedvigBuildConstants, - navigateToConnectPayment = navigateToConnectPayment, + navigateToConnectPayment = navigateToPayinAccount, navigateToConnectPayout = navigateToPayoutAccount, navigateToAddMissingInfo = { contractId: String, type: CoInsuredFlowType -> backstack.add(CoInsuredAddInfoKey(contractId, type)) diff --git a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml index d7d86d08a0..d5d60cced2 100644 --- a/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values-sv-rSE/strings.xml @@ -344,7 +344,7 @@ Avslutas %1$s Se certifikat Populärt val - Upptäck fler försäkringar + Upptäck mer skydd För dig %1$s/mnd - Kort ej kopplat Aktiv @@ -519,7 +519,7 @@ Bjud in en vän Reseintyg Kommande betalning - Uppgradera mitt skydd + Ändra mitt skydd Dina prisförslag Lägg till medförsäkrad Lägg till medägare @@ -645,7 +645,7 @@ Du kan byta konto senare i inställningar Koppla betalning Till appen - Du får 15\u0025 rabatt när du har två eller fler försäkringar. Gäller alla utom olycksfall + Du får 15\u0025 samlingsrabatt när du har två eller fler försäkringar Få samlingsrabatt Gör det senare När någon skaffar Hedvig med din kod får ni båda %1$s rabatt per månad @@ -724,9 +724,37 @@ Rabatt via inbjudningar Betalningsmetod Delsumma + Telefonnummer Kommande betalning Giltig till %1$s + Lägg till betalningsmetod + Change payment method + Välj förvald metod + Din nästa betalning dras från %1$s. Utbetalningar skickas också till detta konto. + Lägg till en ny betalningsmetod för din försäkring + Koppla betalning Betalningshistorik + Betalningsmetoder + Betalningsmetod + Öppna Swish + Kopplad + Faktura till din Kivra + Betalningar och utbetalningar via Swish + Anslut din bank via Trustly + Bekräfta betalningsmetod + Förvald + Den här metoden används för dina månadsbetalningar + Välj betalningsmetod + Du kan lägga till den igen när som helst + Ta bort betalningsmetod + Godkänn kopplingen i Swish + Byt telefonnummer + Så fungerar återkommande betalningar med Swish + Connection failed + Vi skickar kopplingen till Swish på det här numret + Charged automatically each month + Swish is connected + Bekräfta ditt Swish-nummer Lägg till utbetalningsmetod Direktutbetalning via Faktura Snabb utbetalning med Swish @@ -810,10 +838,10 @@ Ett utkast sparas i 7 dagar. Du kan göra en ny anmälan när du vill. Utkastet finns inte kvar Fortsätt - Det du fyllt i sparas som ett utkast i 7 dagar. - Stanna kvar - Lämna - Vill du lämna anmälan? + Det du fyllt i sparas som utkast i 7 dagar. + Fortsätt + Spara utkast + Stäng anmälan? Påbörjad %1$s Spara och fortsätt Inga träffar på din sökning @@ -1227,6 +1255,7 @@ Fördelar Läs mer Få pris + Se pris För din kropp. Gäller dygnet runt. Ingen självrisk. Beräkna ditt pris diff --git a/app/core/core-resources/src/androidMain/res/values/strings.xml b/app/core/core-resources/src/androidMain/res/values/strings.xml index 24ca8ffbb3..618e7114ee 100644 --- a/app/core/core-resources/src/androidMain/res/values/strings.xml +++ b/app/core/core-resources/src/androidMain/res/values/strings.xml @@ -344,7 +344,7 @@ Terminates on %1$s View certificate Popular choice - Discover more insurances + Discover more coverage For you %1$s/mnd - Card not connected Active @@ -519,7 +519,7 @@ Invite a friend Travel certificate Upcoming payment - Upgrade my coverage + Change my coverage Your quotes Add co-insured Add co-owner @@ -645,7 +645,7 @@ You can switch accounts later in settings Connect payment method Go to the app - You get a 15\u0025 discount when you have two or more insurances with us. Applies to all except accident + You get a 15\u0025 bundle discount when you have two or more insurances with us Get bundle discount Do this later When someone gets Hedvig with your code you both get %1$s discount per month @@ -724,9 +724,37 @@ Referral discount Payment method Subtotal + Phone number Upcoming payment Valid until %1$s + Add payment method + Change payment method + Change primary method + Your next payment will be drawn from %1$s. Claims payouts will also be sent to this account. + Add a new payment method for your insurance + Connect payment Payment history + Payment methods + Payment method + Open Swish + Connected + Invoice to your Kivra + Payments and payouts via Swish + Connect your bank via Trustly + Confirm payment method + Primary + This method will be used for your monthly payments + Choose payment method + You can add it back any time + Remove payment method + Approve the connection in Swish + Change phone number + How recurring payments with Swish work + Connection failed + We\'ll send the connection request to Swish on this number + Charged automatically each month + Swish is connected + Confirm your Swish number Add payout method Direct payment via Invoice Instant payout with Swish @@ -811,9 +839,9 @@ This draft has expired Continue What you\'ve filled in is saved as a draft for 7 days. - Stay - Leave - Leave this claim? + Continue + Save draft + Close this claim? Started %1$s Save and continue No results for your search @@ -1227,6 +1255,7 @@ Highlights Learn more Get price + See price For your body. 24/7 coverage. No deductible. For your trip. Worldwide coverage for 45 days. Calculate your price diff --git a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml index 4742c0d51e..deb691d598 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values-sv-rSE/strings.xml @@ -343,7 +343,7 @@ Avslutas %1$s Se certifikat Populärt val - Upptäck fler försäkringar + Upptäck mer skydd För dig %1$s/mnd - Kort ej kopplat Aktiv @@ -518,7 +518,7 @@ Bjud in en vän Reseintyg Kommande betalning - Uppgradera mitt skydd + Ändra mitt skydd Dina prisförslag Lägg till medförsäkrad Lägg till medägare @@ -644,7 +644,7 @@ Du kan byta konto senare i inställningar Koppla betalning Till appen - Du får 15\u0025 rabatt när du har två eller fler försäkringar. Gäller alla utom olycksfall + Du får 15\u0025 samlingsrabatt när du har två eller fler försäkringar Få samlingsrabatt Gör det senare När någon skaffar Hedvig med din kod får ni båda %1$s rabatt per månad @@ -723,9 +723,37 @@ Rabatt via inbjudningar Betalningsmetod Delsumma + Telefonnummer Kommande betalning Giltig till %1$s + Lägg till betalningsmetod + Change payment method + Välj förvald metod + Din nästa betalning dras från %1$s. Utbetalningar skickas också till detta konto. + Lägg till en ny betalningsmetod för din försäkring + Koppla betalning Betalningshistorik + Betalningsmetoder + Betalningsmetod + Öppna Swish + Kopplad + Faktura till din Kivra + Betalningar och utbetalningar via Swish + Anslut din bank via Trustly + Bekräfta betalningsmetod + Förvald + Den här metoden används för dina månadsbetalningar + Välj betalningsmetod + Du kan lägga till den igen när som helst + Ta bort betalningsmetod + Godkänn kopplingen i Swish + Byt telefonnummer + Så fungerar återkommande betalningar med Swish + Connection failed + Vi skickar kopplingen till Swish på det här numret + Charged automatically each month + Swish is connected + Bekräfta ditt Swish-nummer Lägg till utbetalningsmetod Direktutbetalning via Faktura Snabb utbetalning med Swish @@ -809,10 +837,10 @@ Ett utkast sparas i 7 dagar. Du kan göra en ny anmälan när du vill. Utkastet finns inte kvar Fortsätt - Det du fyllt i sparas som ett utkast i 7 dagar. - Stanna kvar - Lämna - Vill du lämna anmälan? + Det du fyllt i sparas som utkast i 7 dagar. + Fortsätt + Spara utkast + Stäng anmälan? Påbörjad %1$s Spara och fortsätt Inga träffar på din sökning @@ -1226,6 +1254,7 @@ Fördelar Läs mer Få pris + Se pris För din kropp. Gäller dygnet runt. Ingen självrisk. Beräkna ditt pris diff --git a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml index 490f9313a5..f63e4976f7 100644 --- a/app/core/core-resources/src/commonMain/composeResources/values/strings.xml +++ b/app/core/core-resources/src/commonMain/composeResources/values/strings.xml @@ -343,7 +343,7 @@ Terminates on %1$s View certificate Popular choice - Discover more insurances + Discover more coverage For you %1$s/mnd - Card not connected Active @@ -518,7 +518,7 @@ Invite a friend Travel certificate Upcoming payment - Upgrade my coverage + Change my coverage Your quotes Add co-insured Add co-owner @@ -644,7 +644,7 @@ You can switch accounts later in settings Connect payment method Go to the app - You get a 15\u0025 discount when you have two or more insurances with us. Applies to all except accident + You get a 15\u0025 bundle discount when you have two or more insurances with us Get bundle discount Do this later When someone gets Hedvig with your code you both get %1$s discount per month @@ -723,9 +723,37 @@ Referral discount Payment method Subtotal + Phone number Upcoming payment Valid until %1$s + Add payment method + Change payment method + Change primary method + Your next payment will be drawn from %1$s. Claims payouts will also be sent to this account. + Add a new payment method for your insurance + Connect payment Payment history + Payment methods + Payment method + Open Swish + Connected + Invoice to your Kivra + Payments and payouts via Swish + Connect your bank via Trustly + Confirm payment method + Primary + This method will be used for your monthly payments + Choose payment method + You can add it back any time + Remove payment method + Approve the connection in Swish + Change phone number + How recurring payments with Swish work + Connection failed + We'll send the connection request to Swish on this number + Charged automatically each month + Swish is connected + Confirm your Swish number Add payout method Direct payment via Invoice Instant payout with Swish @@ -810,9 +838,9 @@ This draft has expired Continue What you've filled in is saved as a draft for 7 days. - Stay - Leave - Leave this claim? + Continue + Save draft + Close this claim? Started %1$s Save and continue No results for your search @@ -1226,6 +1254,7 @@ Highlights Learn more Get price + See price For your body. 24/7 coverage. No deductible. For your trip. Worldwide coverage for 45 days. Calculate your price diff --git a/app/data/data-paying-member/build.gradle.kts b/app/data/data-paying-member/build.gradle.kts index 5d9bd6ff91..8f6acdbb70 100644 --- a/app/data/data-paying-member/build.gradle.kts +++ b/app/data/data-paying-member/build.gradle.kts @@ -5,10 +5,12 @@ plugins { hedvig { apollo("octopus") + serialization() } dependencies { implementation(libs.arrow.core) + implementation(libs.kotlinx.serialization.core) implementation(projects.apolloCore) implementation(projects.apolloOctopusPublic) implementation(projects.coreCommonPublic) diff --git a/app/data/data-paying-member/src/main/kotlin/com/hedvig/android/data/paying/member/PayinAccount.kt b/app/data/data-paying-member/src/main/kotlin/com/hedvig/android/data/paying/member/PayinAccount.kt new file mode 100644 index 0000000000..582c9d4baf --- /dev/null +++ b/app/data/data-paying-member/src/main/kotlin/com/hedvig/android/data/paying/member/PayinAccount.kt @@ -0,0 +1,116 @@ +package com.hedvig.android.data.paying.member + +import kotlinx.serialization.Serializable +import octopus.fragment.MemberPaymentMethodFragment +import octopus.fragment.MemberPaymentMethodFragment.Details.Companion.asPaymentMethodBankAccountDetails +import octopus.fragment.MemberPaymentMethodFragment.Details.Companion.asPaymentMethodInvoiceDetails +import octopus.fragment.MemberPaymentMethodFragment.Details.Companion.asPaymentMethodSwishDetails +import octopus.type.MemberPaymentMethodStatus +import octopus.type.MemberPaymentProvider +import octopus.type.PaymentMethodInvoiceDelivery + +/** Serializable so that a chosen set of methods can be carried in a nav key across process death. */ +@Serializable +sealed interface PayinAccount { + val isPending: Boolean + val isDefault: Boolean + + @Serializable + data class Trustly( + val clearingNumber: String?, + val accountNumber: String?, + val bankName: String?, + override val isPending: Boolean, + override val isDefault: Boolean, + ) : PayinAccount + + @Serializable + data class SwishPayin( + val phoneNumber: String?, + override val isPending: Boolean, + override val isDefault: Boolean, + ) : PayinAccount + + @Serializable + data class Invoice( + val delivery: InvoiceDelivery?, + val email: String?, + override val isPending: Boolean, + override val isDefault: Boolean, + ) : PayinAccount +} + +@Serializable +enum class InvoiceDelivery { + Kivra, + Mail, +} + +/** Null for a provider the app has no screen for, which callers drop from the methods they show. */ +fun MemberPaymentMethodFragment.toPayinAccount(): PayinAccount? { + val isPending = status == MemberPaymentMethodStatus.PENDING + // The backend also marks a pending method default once the member picks it during setup, but + // nothing is charged to it until it activates, so it is not the default the app shows. + val isDefaultAndActive = !isPending && isDefault + return when (provider) { + MemberPaymentProvider.SWISH -> { + PayinAccount.SwishPayin( + phoneNumber = details?.asPaymentMethodSwishDetails()?.phoneNumber, + isPending = isPending, + isDefault = isDefaultAndActive, + ) + } + + MemberPaymentProvider.TRUSTLY -> { + val bankAccountDetails = details?.asPaymentMethodBankAccountDetails() + val account = bankAccountDetails?.account + val dashIndex = account?.indexOf('-') ?: -1 + PayinAccount.Trustly( + clearingNumber = if (dashIndex >= 0) account?.substring(0, dashIndex) else account, + accountNumber = if (dashIndex >= 0) account?.substring(dashIndex + 1) else null, + bankName = bankAccountDetails?.bank, + isPending = isPending, + isDefault = isDefaultAndActive, + ) + } + + MemberPaymentProvider.INVOICE -> { + val invoiceDetails = details?.asPaymentMethodInvoiceDetails() + PayinAccount.Invoice( + delivery = invoiceDetails?.delivery.toInvoiceDelivery(), + email = invoiceDetails?.email, + isPending = isPending, + isDefault = isDefaultAndActive, + ) + } + + else -> { + null + } + } +} + +/** Identifies the method to the backend: a member has at most one method per provider. */ +val PayinAccount.provider: MemberPaymentProvider + get() = when (this) { + is PayinAccount.Trustly -> MemberPaymentProvider.TRUSTLY + is PayinAccount.SwishPayin -> MemberPaymentProvider.SWISH + is PayinAccount.Invoice -> MemberPaymentProvider.INVOICE + } + +/** The last four digits of the account, the only part of it we ever put on screen. */ +fun PayinAccount.Trustly.maskedAccountNumber(): String? { + val clearing = clearingNumber?.takeIf { it.isNotBlank() }.orEmpty() + val account = accountNumber?.takeIf { it.isNotBlank() }.orEmpty() + val whole = clearing + account + if (whole.length <= 8) return null + return "**** ${whole.takeLast(4)}" +} + +private fun PaymentMethodInvoiceDelivery?.toInvoiceDelivery(): InvoiceDelivery? { + return when (this) { + PaymentMethodInvoiceDelivery.KIVRA -> InvoiceDelivery.Kivra + PaymentMethodInvoiceDelivery.MAIL -> InvoiceDelivery.Mail + else -> null + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt index 4cd57a80c0..de99cfe84f 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Button.kt @@ -259,6 +259,7 @@ fun HedvigButtonGhostWithBorder( enabled: Boolean = true, interactionSource: MutableInteractionSource? = null, size: ButtonSize = ButtonSize.Medium, + isLoading: Boolean = false, ) { HedvigTextButton( text = text, @@ -271,6 +272,7 @@ fun HedvigButtonGhostWithBorder( ), buttonSize = size, interactionSource = interactionSource, + isLoading = isLoading, ) } diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/EmptyState.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/EmptyState.kt index ffa183ce3b..ac9673521b 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/EmptyState.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/EmptyState.kt @@ -1,5 +1,6 @@ package com.hedvig.android.design.system.hedvig +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope @@ -37,6 +38,7 @@ import com.hedvig.android.design.system.hedvig.icon.CheckFilled import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.InfoFilled import com.hedvig.android.design.system.hedvig.icon.WarningFilled +import com.hedvig.android.design.system.hedvig.icon.colored.Swish import com.hedvig.android.design.system.hedvig.tokens.EmptyStateTokens @Composable @@ -148,6 +150,15 @@ private fun ColumnScope.EmptyStateIcon(iconStyle: EmptyStateIconStyle) { ) Spacer(Modifier.height(16.dp)) } + + EmptyStateIconStyle.SWISH -> { + Image( + HedvigIcons.Swish, + null, + modifier = Modifier.size(48.dp), + ) + Spacer(Modifier.height(16.dp)) + } } } @@ -162,6 +173,8 @@ object EmptyStateDefaults { BANK_ID, NO_ICON, SUCCESS_WITH_WARNING, + + SWISH, } sealed class EmptyStateButtonStyle { diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Modifier.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Modifier.kt index fb84b2f2e4..267130005c 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Modifier.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/Modifier.kt @@ -22,7 +22,10 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathEffect import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.drawOutline +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.rotate import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalFocusManager @@ -51,6 +54,25 @@ fun Modifier.clearFocusOnTap(): Modifier = this.composed { } } +/** Draws a dashed outline that follows [shape], on top of the content so it is not clipped. */ +fun Modifier.dashedBorder( + color: Color, + shape: Shape, + strokeWidth: Dp = 1.dp, + dashOn: Dp = 4.dp, + dashOff: Dp = 4.dp, +): Modifier = this.drawWithContent { + drawContent() + drawOutline( + outline = shape.createOutline(size, layoutDirection, this), + color = color, + style = Stroke( + width = strokeWidth.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashOn.toPx(), dashOff.toPx())), + ), + ) +} + /** * Used when quickly prototyping something and want to see how it renders */ diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/PaymentMethodHandover.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/PaymentMethodHandover.kt new file mode 100644 index 0000000000..caef501621 --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/PaymentMethodHandover.kt @@ -0,0 +1,177 @@ +package com.hedvig.android.design.system.hedvig + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.HelipadOutline +import com.hedvig.android.design.system.hedvig.icon.Plus + +/** The size a [PaymentMethodTile]'s mark is drawn at. */ +val PaymentMethodMarkSize = 39.dp + +/** The size a [PaymentMethodPillow]'s mark is drawn at. */ +val PaymentMethodPillowMarkSize = 28.dp + +private val TileSize = 74.dp +private val PillowSize = 40.dp +private val BadgeSize = 24.dp +private val BadgeOffset = 8.dp + +/** + * The outlined tile a payment method is shown on while it is being connected or given up, with an + * optional status badge on its top-right corner. [mark] is the method's brand mark, drawn at + * [PaymentMethodMarkSize]; it inherits the tile's content colour, so a monochrome mark reads + * correctly in both themes. + */ +@Composable +fun PaymentMethodTile( + modifier: Modifier = Modifier, + badge: @Composable (() -> Unit)? = null, + mark: @Composable () -> Unit = { PaymentMethodPlusMark(Modifier.size(PaymentMethodMarkSize)) }, +) { + Box(modifier) { + Surface( + shape = HedvigTheme.shapes.cornerXXLarge, + color = HedvigTheme.colorScheme.backgroundPrimary, + contentColor = HedvigTheme.colorScheme.fillPrimary, + modifier = Modifier + .size(TileSize) + .hedvigDropShadow(HedvigTheme.shapes.cornerXXLarge) + .dashedBorder( + color = HedvigTheme.colorScheme.borderSecondary, + shape = HedvigTheme.shapes.cornerXXLarge, + ), + ) { + Box(Modifier.size(TileSize), contentAlignment = Alignment.Center) { + mark() + } + } + if (badge != null) { + Box(Modifier.align(Alignment.TopEnd).offset(BadgeOffset, -BadgeOffset)) { + badge() + } + } + } +} + +/** The mark a [PaymentMethodTile] shows before a method has been picked. */ +@Composable +fun PaymentMethodPlusMark(modifier: Modifier = Modifier) { + Icon( + imageVector = HedvigIcons.Plus, + contentDescription = null, + tint = HedvigTheme.colorScheme.fillSecondary, + modifier = modifier, + ) +} + +/** + * The small tile a payment method's brand mark sits on in a list row. The defaults suit a + * full-colour mark; a monochrome one reads better inverted, on [ColorScheme.fillBlack]. + */ +@Composable +fun PaymentMethodPillow( + modifier: Modifier = Modifier, + containerColor: Color = HedvigTheme.colorScheme.fillWhite, + contentColor: Color = HedvigTheme.colorScheme.fillBlack, + mark: @Composable () -> Unit, +) { + Surface( + shape = HedvigTheme.shapes.cornerSmall, + color = containerColor, + contentColor = contentColor, + modifier = modifier.size(PillowSize), + ) { + Box(Modifier.size(PillowSize), contentAlignment = Alignment.Center) { + mark() + } + } +} + +/** A circular status badge for the corner of a [PaymentMethodTile] or the Hedvig symbol. */ +@Composable +fun PaymentMethodTileBadge( + icon: ImageVector, + containerColor: Color, + contentColor: Color, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(BadgeSize) + .clip(CircleShape) + .background(containerColor), + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = contentColor, + modifier = Modifier.size(16.dp), + ) + } +} + +/** + * The method being connected, linked by pulsing dots to the Hedvig symbol. [destinationBadge] marks + * the symbol once the hand-off has landed. + */ +@Composable +fun PaymentMethodHandoverIllustration( + modifier: Modifier = Modifier, + destinationBadge: @Composable (() -> Unit)? = null, + mark: @Composable () -> Unit = { PaymentMethodPlusMark(Modifier.size(PaymentMethodMarkSize)) }, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + PaymentMethodTile(mark = mark) + ThreeDotsLoading() + Box { + Surface( + shape = HedvigTheme.shapes.cornerXXLarge, + color = HedvigTheme.colorScheme.fillBlack, + contentColor = HedvigTheme.colorScheme.fillWhite, + border = HedvigTheme.colorScheme.borderPrimary, + modifier = Modifier.size(TileSize), + ) { + Box(Modifier.size(TileSize), contentAlignment = Alignment.Center) { + Icon( + imageVector = HedvigIcons.HelipadOutline, + contentDescription = null, + modifier = Modifier.size(65.dp), + ) + } + } + if (destinationBadge != null) { + Box(Modifier.align(Alignment.TopEnd).offset(BadgeOffset, -BadgeOffset)) { + destinationBadge() + } + } + } + } +} + +@HedvigPreview +@Composable +private fun PreviewPaymentMethodHandoverIllustration() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + PaymentMethodHandoverIllustration(modifier = Modifier.size(width = 210.dp, height = 74.dp)) + } + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/RadioGroup.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/RadioGroup.kt index 30fefde5a4..aca80ca89e 100644 --- a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/RadioGroup.kt +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/RadioGroup.kt @@ -45,6 +45,10 @@ import com.hedvig.android.design.system.hedvig.tokens.RadioGroupStyleTokens import kotlin.jvm.JvmInline import org.jetbrains.compose.resources.painterResource +/** + * [optionIcon] replaces [RadioOption.iconResource] for every option, for leading content an + * `ImageVector` cannot express — a brand mark on its own tile, say. + */ @Composable fun RadioGroup( options: List, @@ -57,6 +61,7 @@ fun RadioGroup( disabledOptions: List = emptyList(), enabled: Boolean = true, textEndContent: @Composable ((RadioOptionId) -> Unit)? = null, + optionIcon: @Composable ((RadioOptionId) -> Unit)? = null, ) { val spacings = RadioGroupDefaults.style(size, style) RadioGroup( @@ -69,6 +74,7 @@ fun RadioGroup( enabled = enabled, role = Role.RadioButton, textEndContent = textEndContent, + optionIcon = optionIcon, selectIndicator = { selected, enabled, colors, interactionSource -> RadioSelectIndicator( selected = selected, @@ -273,6 +279,7 @@ private fun RadioGroup( disabledOptions: List = emptyList(), enabled: Boolean = true, textEndContent: @Composable ((RadioOptionId) -> Unit)? = null, + optionIcon: @Composable ((RadioOptionId) -> Unit)? = null, ) { Box(modifier) { if (style.style is RadioGroupStyle.Labeled) { @@ -307,6 +314,7 @@ private fun RadioGroup( interactionSource = interactionSource, selectIndicator = selectIndicator, textEndContent = textEndContent, + optionIcon = optionIcon, modifier = Modifier.optionSelectable( onRadioOptionSelected = onRadioOptionSelected, radioOptionId = option.id, @@ -333,6 +341,7 @@ private fun RadioGroup( style = style, selectIndicator = selectIndicator, textEndContent = textEndContent, + optionIcon = optionIcon, modifier = Modifier .fillMaxWidth() .optionSelectable(onRadioOptionSelected, option.id, selected, enabled, role) @@ -363,6 +372,7 @@ private fun RadioGroup( style = style, selectIndicator = selectIndicator, textEndContent = textEndContent, + optionIcon = optionIcon, modifier = Modifier .fillMaxWidth() .optionSelectable(onRadioOptionSelected, option.id, selected, enabled, role) @@ -457,6 +467,7 @@ private fun RadioOption( modifier: Modifier = Modifier, interactionSource: MutableInteractionSource? = null, textEndContent: @Composable ((RadioOptionId) -> Unit)? = null, + optionIcon: @Composable ((RadioOptionId) -> Unit)? = null, ) { Row( horizontalArrangement = Arrangement.SpaceBetween, @@ -468,7 +479,9 @@ private fun RadioOption( verticalAlignment = Alignment.CenterVertically, modifier = Modifier.weight(1f, false), ) { - if (option.iconResource != null) { + if (optionIcon != null) { + optionIcon(option.id) + } else if (option.iconResource != null) { RadioOptionIcon(option.iconResource) } if (style.style.leftAlignedIndicator) { diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Autogiro.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Autogiro.kt new file mode 100644 index 0000000000..393da43c24 --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Autogiro.kt @@ -0,0 +1,167 @@ +package com.hedvig.android.design.system.hedvig.icon + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon + +val HedvigIcons.Autogiro: ImageVector + get() { + if (_NounCardSubscriptionPayment8118834 != null) { + return _NounCardSubscriptionPayment8118834!! + } + _NounCardSubscriptionPayment8118834 = ImageVector.Builder( + name = "NounCardSubscriptionPayment8118834", + defaultWidth = 110.dp, + defaultHeight = 135.dp, + viewportWidth = 110f, + viewportHeight = 135f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveToRelative(93.54f, 57.92f) + verticalLineToRelative(-6.67f) + curveToRelative(0f, -4.71f, -0f, -8.07f, -0.22f, -10.69f) + curveToRelative(-0.21f, -2.59f, -0.61f, -4.19f, -1.26f, -5.46f) + curveToRelative(-1.3f, -2.55f, -3.37f, -4.62f, -5.92f, -5.92f) + curveToRelative(-1.27f, -0.64f, -2.87f, -1.05f, -5.46f, -1.26f) + curveToRelative(-2.62f, -0.21f, -5.98f, -0.22f, -10.69f, -0.22f) + horizontalLineToRelative(-30f) + curveToRelative(-4.71f, 0f, -8.07f, 0f, -10.69f, 0.22f) + curveToRelative(-2.59f, 0.21f, -4.19f, 0.61f, -5.46f, 1.26f) + curveToRelative(-2.55f, 1.3f, -4.62f, 3.37f, -5.92f, 5.92f) + curveToRelative(-0.64f, 1.27f, -1.05f, 2.87f, -1.26f, 5.46f) + curveToRelative(-0.21f, 2.62f, -0.22f, 5.98f, -0.22f, 10.69f) + verticalLineToRelative(13.33f) + curveToRelative(0f, 4.72f, 0f, 8.07f, 0.22f, 10.69f) + curveToRelative(0.21f, 2.59f, 0.61f, 4.19f, 1.26f, 5.46f) + curveToRelative(1.3f, 2.55f, 3.37f, 4.62f, 5.92f, 5.92f) + curveToRelative(1.27f, 0.64f, 2.87f, 1.05f, 5.46f, 1.26f) + curveToRelative(2.62f, 0.21f, 5.98f, 0.21f, 10.69f, 0.21f) + horizontalLineToRelative(17.08f) + curveToRelative(1.73f, 0f, 3.13f, 1.4f, 3.13f, 3.13f) + reflectiveCurveToRelative(-1.4f, 3.13f, -3.13f, 3.13f) + horizontalLineToRelative(-17.08f) + curveToRelative(-4.61f, 0f, -8.27f, 0f, -11.2f, -0.23f) + curveToRelative(-2.97f, -0.25f, -5.49f, -0.75f, -7.79f, -1.92f) + curveToRelative(-3.72f, -1.9f, -6.75f, -4.93f, -8.65f, -8.65f) + curveToRelative(-1.17f, -2.3f, -1.68f, -4.81f, -1.92f, -7.78f) + curveToRelative(-0.24f, -2.94f, -0.24f, -6.59f, -0.24f, -11.2f) + verticalLineToRelative(-13.33f) + curveToRelative(0f, -4.61f, -0f, -8.27f, 0.24f, -11.2f) + curveToRelative(0.24f, -2.97f, 0.75f, -5.49f, 1.92f, -7.79f) + curveToRelative(1.9f, -3.72f, 4.93f, -6.75f, 8.65f, -8.65f) + curveToRelative(2.3f, -1.17f, 4.81f, -1.68f, 7.79f, -1.92f) + curveToRelative(2.93f, -0.24f, 6.59f, -0.24f, 11.2f, -0.24f) + horizontalLineToRelative(30f) + curveToRelative(4.61f, 0f, 8.27f, -0f, 11.2f, 0.24f) + curveToRelative(2.97f, 0.24f, 5.49f, 0.75f, 7.79f, 1.92f) + curveToRelative(3.72f, 1.9f, 6.75f, 4.93f, 8.65f, 8.65f) + curveToRelative(1.17f, 2.3f, 1.68f, 4.81f, 1.92f, 7.79f) + curveToRelative(0.24f, 2.93f, 0.24f, 6.59f, 0.24f, 11.2f) + verticalLineToRelative(6.67f) + curveToRelative(0f, 1.72f, -1.4f, 3.13f, -3.13f, 3.13f) + curveToRelative(-1.73f, 0f, -3.13f, -1.4f, -3.13f, -3.13f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(94.58f, 42.29f) + verticalLineToRelative(6.25f) + horizontalLineToRelative(-79.16f) + verticalLineToRelative(-6.25f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(46.67f, 71.46f) + curveToRelative(1.72f, 0f, 3.13f, 1.4f, 3.13f, 3.13f) + curveToRelative(0f, 1.73f, -1.4f, 3.13f, -3.13f, 3.13f) + horizontalLineToRelative(-20.84f) + curveToRelative(-1.72f, 0f, -3.13f, -1.4f, -3.13f, -3.13f) + curveToRelative(0f, -1.72f, 1.4f, -3.13f, 3.13f, -3.13f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(93.54f, 76.67f) + curveToRelative(0f, -2.88f, -2.33f, -5.21f, -5.21f, -5.21f) + horizontalLineToRelative(-18.75f) + curveToRelative(-1.72f, 0f, -3.13f, -1.4f, -3.13f, -3.13f) + curveToRelative(0f, -1.72f, 1.4f, -3.13f, 3.13f, -3.13f) + horizontalLineToRelative(18.75f) + curveToRelative(6.33f, 0f, 11.46f, 5.13f, 11.46f, 11.46f) + curveToRelative(0f, 1.72f, -1.4f, 3.13f, -3.13f, 3.13f) + curveToRelative(-1.73f, 0f, -3.13f, -1.4f, -3.13f, -3.13f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(71.54f, 59.88f) + curveToRelative(1.22f, -1.22f, 3.2f, -1.22f, 4.42f, 0f) + curveToRelative(1.22f, 1.22f, 1.22f, 3.2f, 0f, 4.42f) + lineToRelative(-4.04f, 4.04f) + lineToRelative(4.04f, 4.04f) + curveToRelative(1.22f, 1.22f, 1.22f, 3.2f, 0f, 4.42f) + curveToRelative(-1.22f, 1.22f, -3.2f, 1.22f, -4.42f, 0f) + lineToRelative(-6.25f, -6.25f) + curveToRelative(-1.22f, -1.22f, -1.22f, -3.2f, 0f, -4.42f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(70.63f, 82.92f) + curveToRelative(0f, 2.88f, 2.33f, 5.21f, 5.21f, 5.21f) + horizontalLineToRelative(18.75f) + curveToRelative(1.73f, 0f, 3.13f, 1.4f, 3.13f, 3.13f) + reflectiveCurveToRelative(-1.4f, 3.13f, -3.13f, 3.13f) + horizontalLineToRelative(-18.75f) + curveToRelative(-6.33f, 0f, -11.46f, -5.13f, -11.46f, -11.46f) + curveToRelative(0f, -1.73f, 1.4f, -3.13f, 3.13f, -3.13f) + reflectiveCurveToRelative(3.13f, 1.4f, 3.13f, 3.13f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveToRelative(92.63f, 99.71f) + curveToRelative(-1.22f, 1.22f, -3.2f, 1.22f, -4.42f, 0f) + curveToRelative(-1.22f, -1.22f, -1.22f, -3.2f, 0f, -4.42f) + lineToRelative(4.04f, -4.04f) + lineToRelative(-4.04f, -4.04f) + curveToRelative(-1.22f, -1.22f, -1.22f, -3.2f, 0f, -4.42f) + curveToRelative(1.22f, -1.22f, 3.2f, -1.22f, 4.42f, 0f) + lineToRelative(6.25f, 6.25f) + curveToRelative(1.22f, 1.22f, 1.22f, 3.2f, 0f, 4.42f) + close() + } + }.build() + + return _NounCardSubscriptionPayment8118834!! + } + +@Suppress("ObjectPropertyName") +private var _NounCardSubscriptionPayment8118834: ImageVector? = null + +@Preview +@Composable +private fun IconPreview() { + HedvigTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = HedvigIcons.Autogiro, + contentDescription = com.hedvig.android.compose.ui.EmptyContentDescription, + modifier = Modifier + .width((24.0).dp) + .height((24.0).dp), + ) + } + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/BankAccount.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/BankAccount.kt new file mode 100644 index 0000000000..1a9a3ee2d2 --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/BankAccount.kt @@ -0,0 +1,117 @@ +package com.hedvig.android.design.system.hedvig.icon + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon + +val HedvigIcons.BankAccount: ImageVector + get() { + if (_Icons8BankAccount48 != null) { + return _Icons8BankAccount48!! + } + _Icons8BankAccount48 = ImageVector.Builder( + name = "Icons8BankAccount48", + defaultWidth = 48.dp, + defaultHeight = 48.dp, + viewportWidth = 48f, + viewportHeight = 48f, + ).apply { + path(fill = SolidColor(Color.Black)) { + moveTo(30.867f, 6.531f) + curveTo(32.209f, 7.176f, 33.545f, 7.832f, 34.875f, 8.5f) + curveTo(35.563f, 8.834f, 36.252f, 9.168f, 36.961f, 9.512f) + curveTo(38.646f, 10.331f, 40.324f, 11.164f, 42f, 12f) + curveTo(42f, 13.98f, 42f, 15.96f, 42f, 18f) + curveTo(40.68f, 18f, 39.36f, 18f, 38f, 18f) + curveTo(38f, 20.31f, 38f, 22.62f, 38f, 25f) + curveTo(36.02f, 25.99f, 36.02f, 25.99f, 34f, 27f) + curveTo(34f, 24.03f, 34f, 21.06f, 34f, 18f) + curveTo(32.68f, 18f, 31.36f, 18f, 30f, 18f) + curveTo(30f, 23.28f, 30f, 28.56f, 30f, 34f) + curveTo(28.68f, 34f, 27.36f, 34f, 26f, 34f) + curveTo(26f, 28.72f, 26f, 23.44f, 26f, 18f) + curveTo(24.68f, 18f, 23.36f, 18f, 22f, 18f) + curveTo(22f, 23.28f, 22f, 28.56f, 22f, 34f) + curveTo(20.68f, 34f, 19.36f, 34f, 18f, 34f) + curveTo(18f, 28.72f, 18f, 23.44f, 18f, 18f) + curveTo(16.68f, 18f, 15.36f, 18f, 14f, 18f) + curveTo(14f, 23.28f, 14f, 28.56f, 14f, 34f) + curveTo(12.68f, 34f, 11.36f, 34f, 10f, 34f) + curveTo(10f, 28.72f, 10f, 23.44f, 10f, 18f) + curveTo(8.68f, 18f, 7.36f, 18f, 6f, 18f) + curveTo(6f, 16.02f, 6f, 14.04f, 6f, 12f) + curveTo(8.369f, 10.819f, 10.744f, 9.655f, 13.125f, 8.5f) + curveTo(13.79f, 8.166f, 14.455f, 7.832f, 15.141f, 7.488f) + curveTo(20.813f, 4.763f, 24.925f, 3.968f, 30.867f, 6.531f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveTo(40f, 39.75f) + curveTo(40.866f, 39.745f, 41.732f, 39.74f, 42.625f, 39.734f) + curveTo(45f, 40f, 45f, 40f, 48f, 42f) + curveTo(48f, 43.98f, 48f, 45.96f, 48f, 48f) + curveTo(42.72f, 48f, 37.44f, 48f, 32f, 48f) + curveTo(32f, 46.02f, 32f, 44.04f, 32f, 42f) + curveTo(35.239f, 39.841f, 36.249f, 39.728f, 40f, 39.75f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveTo(6f, 38f) + curveTo(14.91f, 38f, 23.82f, 38f, 33f, 38f) + curveTo(30f, 42f, 30f, 42f, 27.456f, 42.454f) + curveTo(26.467f, 42.433f, 25.477f, 42.412f, 24.457f, 42.391f) + curveTo(23.384f, 42.378f, 22.311f, 42.365f, 21.205f, 42.352f) + curveTo(20.086f, 42.318f, 18.966f, 42.285f, 17.813f, 42.25f) + curveTo(16.681f, 42.232f, 15.55f, 42.214f, 14.385f, 42.195f) + curveTo(11.589f, 42.148f, 8.795f, 42.082f, 6f, 42f) + curveTo(6f, 40.68f, 6f, 39.36f, 6f, 38f) + close() + } + path(fill = SolidColor(Color.Black)) { + moveTo(40f, 27.875f) + curveTo(43f, 28f, 43f, 28f, 44f, 29f) + curveTo(44.125f, 32f, 44.125f, 32f, 44f, 35f) + curveTo(43f, 36f, 43f, 36f, 40f, 36.125f) + curveTo(37f, 36f, 37f, 36f, 36f, 35f) + curveTo(35.875f, 32f, 35.875f, 32f, 36f, 29f) + curveTo(37f, 28f, 37f, 28f, 40f, 27.875f) + close() + } + }.build() + + return _Icons8BankAccount48!! + } + +@Suppress("ObjectPropertyName") +private var _Icons8BankAccount48: ImageVector? = null + +@Preview +@Composable +private fun IconPreview() { + HedvigTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = HedvigIcons.BankAccount, + contentDescription = com.hedvig.android.compose.ui.EmptyContentDescription, + modifier = Modifier + .width((24.0).dp) + .height((24.0).dp), + ) + } + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Trustly.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Trustly.kt new file mode 100644 index 0000000000..c65d7e7e7f --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/Trustly.kt @@ -0,0 +1,73 @@ +package com.hedvig.android.design.system.hedvig.icon + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.LocalContentColor + +/** + * A single monochrome path filled with the content color, so the mark stays legible in both themes + * even where it is drawn without a tint. + */ +val HedvigIcons.Trustly: ImageVector + @Composable + get() { + val color = LocalContentColor.current + return remember(color) { trustlyImageVector(color) } + } + +private fun trustlyImageVector(color: Color): ImageVector = ImageVector.Builder( + name = "Trustly", + defaultWidth = 40.dp, + defaultHeight = 32.dp, + viewportWidth = 40f, + viewportHeight = 32f, +).apply { + path(fill = SolidColor(color)) { + moveTo(0f, 14.018f) + horizontalLineTo(13.992f) + verticalLineTo(2.107f) + lineTo(25.164f, 14.018f) + lineTo(13.992f, 25.91f) + verticalLineTo(32f) + horizontalLineTo(27.553f) + verticalLineTo(14.018f) + horizontalLineTo(40f) + verticalLineTo(0f) + horizontalLineTo(0f) + verticalLineTo(14.018f) + close() + } +}.build() + +@Preview +@Composable +private fun IconPreview() { + HedvigTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + imageVector = HedvigIcons.Trustly, + contentDescription = com.hedvig.android.compose.ui.EmptyContentDescription, + modifier = Modifier + .width((24.0).dp) + .height((24.0).dp), + ) + } + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Kivra.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Kivra.kt new file mode 100644 index 0000000000..6cc8993f9e --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Kivra.kt @@ -0,0 +1,332 @@ +package com.hedvig.android.design.system.hedvig.icon.colored + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons + +val HedvigIcons.Kivra: ImageVector + get() { + if (_KivraLogo1920X1080Green != null) { + return _KivraLogo1920X1080Green!! + } + _KivraLogo1920X1080Green = ImageVector.Builder( + name = "KivraLogo1920X1080Green", + defaultWidth = 1912.dp, + defaultHeight = 1015.dp, + viewportWidth = 1912f, + viewportHeight = 1015f, + ).apply { + path(fill = SolidColor(Color(0xFF75C72E))) { + moveTo(1274f, 292f) + curveTo(1274.9f, 292.2f, 1275.7f, 292.4f, 1276.6f, 292.6f) + curveTo(1303.7f, 299.6f, 1327.7f, 312.8f, 1348f, 332f) + curveTo(1348.8f, 332.7f, 1349.6f, 333.5f, 1350.5f, 334.3f) + curveTo(1376.4f, 358.9f, 1389.9f, 394.6f, 1391.2f, 429.9f) + curveTo(1392.1f, 467.8f, 1380.2f, 502.3f, 1354f, 530f) + curveTo(1353f, 531.1f, 1353f, 531.1f, 1352f, 532.2f) + curveTo(1338.7f, 546.2f, 1319.6f, 558.6f, 1301f, 564f) + curveTo(1300f, 564f, 1299f, 564f, 1298f, 564f) + curveTo(1300.6f, 566.9f, 1303.7f, 568.9f, 1307f, 571.1f) + curveTo(1351.8f, 601.8f, 1380f, 651.6f, 1391.3f, 704f) + curveTo(1391.5f, 704.7f, 1391.6f, 705.5f, 1391.8f, 706.2f) + curveTo(1393.1f, 712.5f, 1393.5f, 718.4f, 1394f, 725f) + curveTo(1366.3f, 725f, 1338.6f, 725f, 1310f, 725f) + curveTo(1308.3f, 716.8f, 1306.7f, 708.5f, 1305f, 700f) + curveTo(1297.1f, 673.4f, 1285.3f, 649.9f, 1267f, 629f) + curveTo(1266.2f, 628.1f, 1265.4f, 627.2f, 1264.6f, 626.2f) + curveTo(1258.9f, 619.7f, 1252.8f, 614.3f, 1246f, 609f) + curveTo(1245.1f, 608.3f, 1244.2f, 607.5f, 1243.2f, 606.8f) + curveTo(1226.4f, 593.6f, 1206.7f, 584.4f, 1186.2f, 578.9f) + curveTo(1183f, 578f, 1183f, 578f, 1182f, 577f) + curveTo(1181.9f, 575.5f, 1181.9f, 574f, 1181.9f, 572.5f) + curveTo(1181.9f, 571f, 1181.9f, 571f, 1181.9f, 569.6f) + curveTo(1181.9f, 568.5f, 1181.9f, 567.5f, 1181.9f, 566.4f) + curveTo(1181.9f, 565.3f, 1181.9f, 564.3f, 1181.9f, 563.2f) + curveTo(1181.9f, 559.8f, 1181.9f, 556.4f, 1181.9f, 552.9f) + curveTo(1181.9f, 550.6f, 1181.9f, 548.3f, 1182f, 546f) + curveTo(1182f, 540.3f, 1182f, 534.7f, 1182f, 529f) + curveTo(1183.2f, 528.9f, 1183.2f, 528.9f, 1184.4f, 528.9f) + curveTo(1201.2f, 528f, 1217.6f, 526.8f, 1234f, 523f) + curveTo(1235.1f, 522.7f, 1235.1f, 522.7f, 1236.3f, 522.5f) + curveTo(1259.9f, 516.8f, 1282.9f, 504.6f, 1296.3f, 483.7f) + curveTo(1308.5f, 462.6f, 1311f, 439.4f, 1305f, 416f) + curveTo(1299.4f, 397.5f, 1287.8f, 382.6f, 1271f, 373f) + curveTo(1250.9f, 362.6f, 1225.7f, 360.1f, 1203.9f, 366.6f) + curveTo(1183f, 373.7f, 1166.3f, 387.1f, 1156f, 406.7f) + curveTo(1147.2f, 424.7f, 1144.9f, 443.9f, 1144.8f, 463.8f) + curveTo(1144.8f, 464.9f, 1144.8f, 466.1f, 1144.8f, 467.2f) + curveTo(1144.8f, 471f, 1144.8f, 474.7f, 1144.8f, 478.4f) + curveTo(1144.8f, 481.1f, 1144.8f, 483.8f, 1144.8f, 486.5f) + curveTo(1144.7f, 492.3f, 1144.7f, 498.1f, 1144.7f, 503.8f) + curveTo(1144.7f, 512.2f, 1144.7f, 520.5f, 1144.6f, 528.9f) + curveTo(1144.6f, 542.4f, 1144.5f, 556f, 1144.5f, 569.5f) + curveTo(1144.5f, 582.7f, 1144.4f, 595.9f, 1144.4f, 609f) + curveTo(1144.4f, 609.8f, 1144.4f, 610.6f, 1144.4f, 611.5f) + curveTo(1144.4f, 615.6f, 1144.3f, 619.6f, 1144.3f, 623.7f) + curveTo(1144.2f, 657.5f, 1144.1f, 691.2f, 1144f, 725f) + curveTo(1117.6f, 725f, 1091.2f, 725f, 1064f, 725f) + curveTo(1063.9f, 688f, 1063.9f, 651f, 1063.8f, 614f) + curveTo(1063.8f, 609.7f, 1063.8f, 605.3f, 1063.8f, 600.9f) + curveTo(1063.8f, 600f, 1063.8f, 599.2f, 1063.8f, 598.3f) + curveTo(1063.8f, 584.2f, 1063.8f, 570.1f, 1063.8f, 556.1f) + curveTo(1063.7f, 541.6f, 1063.7f, 527.2f, 1063.7f, 512.7f) + curveTo(1063.7f, 503.8f, 1063.7f, 494.9f, 1063.7f, 486f) + curveTo(1063.7f, 479.9f, 1063.7f, 473.7f, 1063.7f, 467.6f) + curveTo(1063.7f, 464.1f, 1063.7f, 460.6f, 1063.6f, 457.1f) + curveTo(1063.6f, 438f, 1064.2f, 419.5f, 1069.8f, 401.2f) + curveTo(1070.2f, 399.9f, 1070.2f, 399.9f, 1070.6f, 398.6f) + curveTo(1078.1f, 374f, 1090.2f, 353.5f, 1108f, 335f) + curveTo(1108.7f, 334.2f, 1109.4f, 333.4f, 1110.2f, 332.6f) + curveTo(1126.1f, 315.8f, 1147.4f, 304.6f, 1169f, 297f) + curveTo(1169.7f, 296.7f, 1170.4f, 296.5f, 1171.2f, 296.2f) + curveTo(1176f, 294.6f, 1181f, 293.2f, 1186f, 292f) + curveTo(1186.8f, 291.8f, 1187.6f, 291.6f, 1188.4f, 291.4f) + curveTo(1215.5f, 285.1f, 1247.1f, 285.3f, 1274f, 292f) + close() + } + path(fill = SolidColor(Color(0xFF75C72E))) { + moveTo(141f, 295f) + curveTo(167.4f, 295f, 193.8f, 295f, 221f, 295f) + curveTo(221.3f, 360.3f, 221.7f, 425.7f, 222f, 493f) + curveTo(225f, 489.4f, 227.9f, 485.7f, 231f, 482f) + curveTo(232.6f, 480.1f, 234.3f, 478.3f, 236f, 476.5f) + curveTo(239.3f, 472.9f, 242.4f, 469.3f, 245.5f, 465.5f) + curveTo(249.3f, 460.7f, 253.4f, 456.2f, 257.5f, 451.8f) + curveTo(259.9f, 449.1f, 262.2f, 446.3f, 264.5f, 443.5f) + curveTo(268.3f, 438.7f, 272.4f, 434.2f, 276.5f, 429.8f) + curveTo(278.9f, 427.1f, 281.2f, 424.3f, 283.5f, 421.5f) + curveTo(287.3f, 416.7f, 291.4f, 412.2f, 295.5f, 407.8f) + curveTo(297.9f, 405.1f, 300.2f, 402.3f, 302.5f, 399.5f) + curveTo(306.3f, 394.7f, 310.4f, 390.2f, 314.5f, 385.7f) + curveTo(316.9f, 383.1f, 319.2f, 380.4f, 321.4f, 377.6f) + curveTo(325.1f, 373f, 328.9f, 368.7f, 332.9f, 364.5f) + curveTo(336.7f, 360.4f, 340.2f, 356.1f, 343.8f, 351.7f) + curveTo(346.1f, 348.9f, 348.5f, 346.2f, 351f, 343.5f) + curveTo(354.3f, 339.9f, 357.4f, 336.3f, 360.5f, 332.5f) + curveTo(363.6f, 328.7f, 366.7f, 325.1f, 370f, 321.5f) + curveTo(374f, 317.1f, 377.8f, 312.6f, 381.5f, 308f) + curveTo(382.3f, 307f, 383.2f, 306f, 384f, 305f) + curveTo(384.5f, 304.2f, 384.9f, 303.4f, 385.4f, 302.6f) + curveTo(388f, 298.8f, 390.5f, 295.6f, 395.1f, 294.4f) + curveTo(401.8f, 293.6f, 408.4f, 293.9f, 415.1f, 294f) + curveTo(417.3f, 294f, 419.4f, 294.1f, 421.6f, 294.1f) + curveTo(427.3f, 294.1f, 432.9f, 294.2f, 438.6f, 294.3f) + curveTo(444.4f, 294.4f, 450.2f, 294.5f, 456f, 294.5f) + curveTo(467.3f, 294.6f, 478.7f, 294.8f, 490f, 295f) + curveTo(488.3f, 298.7f, 486.1f, 301.3f, 483.3f, 304.3f) + curveTo(482.4f, 305.2f, 481.5f, 306.2f, 480.6f, 307.2f) + curveTo(479.3f, 308.6f, 479.3f, 308.6f, 478f, 310f) + curveTo(474.7f, 313.7f, 471.5f, 317.4f, 468.3f, 321.2f) + curveTo(462.5f, 328f, 456.5f, 334.7f, 450.4f, 341.3f) + curveTo(446.3f, 345.9f, 442.3f, 350.5f, 438.3f, 355.1f) + curveTo(432.8f, 361.7f, 427.1f, 368.1f, 421.3f, 374.3f) + curveTo(418.2f, 377.6f, 415.4f, 380.9f, 412.6f, 384.4f) + curveTo(408f, 390.2f, 403f, 395.5f, 398f, 400.8f) + curveTo(393.6f, 405.5f, 389.5f, 410.3f, 385.3f, 415.1f) + curveTo(379.8f, 421.7f, 374.1f, 428.1f, 368.3f, 434.3f) + curveTo(364.5f, 438.4f, 360.9f, 442.7f, 357.4f, 447.1f) + curveTo(354f, 451.2f, 350.3f, 455.2f, 346.5f, 459.1f) + curveTo(345.5f, 460.2f, 344.4f, 461.3f, 343.4f, 462.4f) + curveTo(342.1f, 463.9f, 340.7f, 465.3f, 339.3f, 466.6f) + curveTo(337.7f, 470.8f, 338.3f, 471.7f, 340f, 475.7f) + curveTo(341.1f, 478f, 342.3f, 480.3f, 343.4f, 482.6f) + curveTo(344.1f, 483.9f, 344.7f, 485.1f, 345.3f, 486.4f) + curveTo(346.5f, 489f, 347.8f, 491.6f, 349.1f, 494.1f) + curveTo(352f, 500f, 354.8f, 506f, 357.5f, 512f) + curveTo(358.6f, 514.3f, 359.7f, 516.7f, 360.8f, 519f) + curveTo(363.4f, 524.8f, 366.1f, 530.6f, 368.8f, 536.4f) + curveTo(370.4f, 539.9f, 372.1f, 543.4f, 373.7f, 546.8f) + curveTo(375.7f, 551.1f, 377.6f, 555.4f, 379.6f, 559.7f) + curveTo(404.1f, 616.1f, 404.1f, 616.1f, 451.5f, 651.9f) + curveTo(459.7f, 654.5f, 467.4f, 655.3f, 476f, 655.2f) + curveTo(476.9f, 655.2f, 477.8f, 655.2f, 478.8f, 655.2f) + curveTo(485f, 655.1f, 490.8f, 654.6f, 497f, 654f) + curveTo(497f, 677.1f, 497f, 700.2f, 497f, 724f) + curveTo(485.2f, 726.1f, 474.5f, 727.5f, 462.7f, 727.4f) + curveTo(461.9f, 727.4f, 461.2f, 727.4f, 460.4f, 727.4f) + curveTo(448.2f, 727.4f, 436.7f, 726.5f, 425f, 723f) + curveTo(423.9f, 722.7f, 423.9f, 722.7f, 422.8f, 722.4f) + curveTo(401.1f, 715.8f, 383.2f, 703.6f, 367f, 688f) + curveTo(366.2f, 687.2f, 365.3f, 686.5f, 364.5f, 685.7f) + curveTo(333.2f, 656f, 316.1f, 612.6f, 298.4f, 574.3f) + curveTo(296f, 569f, 293.5f, 563.8f, 291.1f, 558.6f) + curveTo(290.4f, 557.2f, 289.8f, 555.9f, 289.1f, 554.5f) + curveTo(288.2f, 552.4f, 287.2f, 550.4f, 286.2f, 548.4f) + curveTo(285.4f, 546.5f, 284.5f, 544.6f, 283.6f, 542.8f) + curveTo(282.8f, 541.2f, 282.8f, 541.2f, 282.1f, 539.5f) + curveTo(281f, 537f, 281f, 537f, 281f, 535f) + curveTo(276f, 538.9f, 272.1f, 543.2f, 268f, 548f) + curveTo(266.1f, 550.3f, 264.2f, 552.5f, 262.3f, 554.8f) + curveTo(261.7f, 555.4f, 261.2f, 556f, 260.7f, 556.6f) + curveTo(256.4f, 561.6f, 251.9f, 566.5f, 247.5f, 571.3f) + curveTo(244.8f, 574.2f, 242.3f, 577.1f, 239.7f, 580f) + curveTo(237f, 583.1f, 234.2f, 586.2f, 231.4f, 589.3f) + curveTo(230.6f, 590.2f, 229.8f, 591.1f, 229f, 592f) + curveTo(228.4f, 592.5f, 227.9f, 593f, 227.3f, 593.6f) + curveTo(222.3f, 598.4f, 221.2f, 602.1f, 221.1f, 608.9f) + curveTo(221.1f, 610.1f, 221.1f, 611.2f, 221.1f, 612.4f) + curveTo(221.1f, 613.6f, 221.1f, 614.8f, 221.1f, 616.1f) + curveTo(221.1f, 619.4f, 221.1f, 622.7f, 221.1f, 626.1f) + curveTo(221.1f, 629.5f, 221.1f, 633f, 221.1f, 636.5f) + curveTo(221f, 643.1f, 221f, 649.7f, 221.1f, 656.2f) + curveTo(221.1f, 664.5f, 221f, 672.7f, 221f, 681f) + curveTo(221f, 695.6f, 221f, 710.3f, 221f, 725f) + curveTo(194.6f, 725f, 168.2f, 725f, 141f, 725f) + curveTo(141f, 583.1f, 141f, 441.2f, 141f, 295f) + close() + } + path(fill = SolidColor(Color(0xFF75C72E))) { + moveTo(1567f, 295f) + curveTo(1596.7f, 295f, 1626.4f, 295f, 1657f, 295f) + curveTo(1661.8f, 303.4f, 1666.6f, 311.8f, 1671f, 320.4f) + curveTo(1675f, 328.2f, 1675f, 328.2f, 1679.1f, 335.8f) + curveTo(1685.1f, 346.8f, 1690.3f, 358.2f, 1695.5f, 369.6f) + curveTo(1697.3f, 373.4f, 1699.1f, 377.3f, 1700.9f, 381.1f) + curveTo(1716.7f, 414.6f, 1730.4f, 449.1f, 1743f, 484f) + curveTo(1743.5f, 485.3f, 1743.5f, 485.3f, 1743.9f, 486.6f) + curveTo(1768.6f, 554.9f, 1787.8f, 624.7f, 1802.1f, 695.9f) + curveTo(1802.3f, 696.8f, 1802.5f, 697.8f, 1802.7f, 698.8f) + curveTo(1803.3f, 701.5f, 1803.8f, 704.2f, 1804.3f, 706.9f) + curveTo(1804.5f, 707.7f, 1804.6f, 708.5f, 1804.8f, 709.3f) + curveTo(1805.8f, 714.6f, 1806.4f, 719.7f, 1807f, 725f) + curveTo(1780.3f, 725f, 1753.5f, 725f, 1726f, 725f) + curveTo(1720.4f, 695.4f, 1720.4f, 695.4f, 1718.7f, 686.3f) + curveTo(1716.1f, 672.4f, 1713.3f, 658.5f, 1710.2f, 644.6f) + curveTo(1710f, 643.9f, 1709.9f, 643.3f, 1709.7f, 642.6f) + curveTo(1708.1f, 635.2f, 1708.1f, 635.2f, 1706f, 628f) + curveTo(1705.2f, 628.1f, 1704.4f, 628.3f, 1703.6f, 628.4f) + curveTo(1687.4f, 631.3f, 1671.4f, 633.9f, 1655f, 635f) + curveTo(1653.9f, 635.1f, 1652.7f, 635.2f, 1651.5f, 635.3f) + curveTo(1638.4f, 636.2f, 1625.4f, 636.2f, 1612.3f, 636.2f) + curveTo(1610.5f, 636.2f, 1610.5f, 636.2f, 1608.7f, 636.2f) + curveTo(1577.4f, 636.1f, 1546.8f, 633.8f, 1516f, 628f) + curveTo(1515.9f, 629.1f, 1515.8f, 630.2f, 1515.6f, 631.4f) + curveTo(1515f, 636.2f, 1514f, 640.9f, 1513f, 645.7f) + curveTo(1512.5f, 647.6f, 1512.1f, 649.5f, 1511.7f, 651.4f) + curveTo(1511.5f, 652.4f, 1511.3f, 653.4f, 1511f, 654.4f) + curveTo(1506.9f, 673.2f, 1503.2f, 692f, 1500.1f, 711f) + curveTo(1499.9f, 711.8f, 1499.8f, 712.6f, 1499.7f, 713.4f) + curveTo(1499.4f, 714.9f, 1499.2f, 716.4f, 1498.9f, 717.9f) + curveTo(1498.1f, 722.8f, 1498.1f, 722.8f, 1497f, 725f) + curveTo(1470.3f, 725f, 1443.5f, 725f, 1416f, 725f) + curveTo(1424.2f, 680.4f, 1424.2f, 680.4f, 1427.8f, 664.8f) + curveTo(1428.1f, 663.4f, 1428.1f, 663.4f, 1428.4f, 662f) + curveTo(1448.5f, 572.7f, 1477.2f, 485.7f, 1531f, 364f) + curveTo(1531.6f, 362.7f, 1532.3f, 361.3f, 1532.9f, 360f) + curveTo(1543.5f, 337.8f, 1554.8f, 316.3f, 1567f, 295f) + close() + moveTo(1611f, 367f) + curveTo(1600.2f, 388f, 1590.3f, 409.2f, 1580.9f, 430.9f) + curveTo(1580f, 432.9f, 1579.1f, 435f, 1578.3f, 437f) + curveTo(1563.9f, 470f, 1552f, 503.8f, 1541f, 538f) + curveTo(1540.8f, 538.7f, 1540.5f, 539.5f, 1540.3f, 540.2f) + curveTo(1539.6f, 542.4f, 1538.9f, 544.6f, 1538.2f, 546.9f) + curveTo(1537.8f, 547.9f, 1537.8f, 547.9f, 1537.5f, 548.9f) + curveTo(1536.6f, 551.7f, 1536f, 554f, 1536f, 557f) + curveTo(1539.5f, 557.6f, 1543.1f, 558.3f, 1546.6f, 558.9f) + curveTo(1547.6f, 559f, 1548.5f, 559.2f, 1549.5f, 559.4f) + curveTo(1569.9f, 562.9f, 1590.3f, 563.4f, 1611f, 563.3f) + curveTo(1612.9f, 563.3f, 1612.9f, 563.3f, 1614.9f, 563.3f) + curveTo(1633.8f, 563.3f, 1652.3f, 562.9f, 1671f, 560f) + curveTo(1671.9f, 559.9f, 1672.9f, 559.7f, 1673.8f, 559.6f) + curveTo(1678.6f, 558.8f, 1683.3f, 558f, 1688f, 557f) + curveTo(1671.9f, 500.9f, 1650.4f, 446.8f, 1625.6f, 394f) + curveTo(1625.1f, 392.9f, 1625.1f, 392.9f, 1624.6f, 391.8f) + curveTo(1620.7f, 383.4f, 1616.6f, 375.1f, 1612f, 367f) + curveTo(1611.7f, 367f, 1611.3f, 367f, 1611f, 367f) + close() + } + path(fill = SolidColor(Color(0xFF75C72E))) { + moveTo(657f, 295f) + curveTo(683.7f, 295f, 710.5f, 295f, 738f, 295f) + curveTo(739.9f, 305.1f, 739.9f, 305.1f, 741.9f, 315.5f) + curveTo(759.9f, 407.5f, 785.9f, 497.9f, 845.1f, 637.5f) + curveTo(845.5f, 638.4f, 845.9f, 639.2f, 846.3f, 640f) + curveTo(846.6f, 640.8f, 846.9f, 641.5f, 847.3f, 642.2f) + curveTo(848f, 644f, 848f, 644f, 848f, 646f) + curveTo(848.7f, 646f, 849.3f, 646f, 850f, 646f) + curveTo(850.3f, 645.2f, 850.5f, 644.3f, 850.8f, 643.5f) + curveTo(852.1f, 639.8f, 853.6f, 636.2f, 855.3f, 632.6f) + curveTo(856f, 631.2f, 856.6f, 629.7f, 857.3f, 628.2f) + curveTo(857.8f, 627f, 857.8f, 627f, 858.4f, 625.8f) + curveTo(874.2f, 591f, 888.4f, 555.6f, 901.1f, 519.5f) + curveTo(902f, 517f, 902.9f, 514.5f, 903.8f, 512f) + curveTo(927.1f, 446.2f, 944.6f, 378.5f, 957.8f, 310f) + curveTo(958.8f, 305f, 959.9f, 300f, 961f, 295f) + curveTo(987.4f, 295f, 1013.8f, 295f, 1041f, 295f) + curveTo(1040.4f, 301.7f, 1039.8f, 308.2f, 1038.5f, 314.8f) + curveTo(1038.4f, 315.6f, 1038.2f, 316.4f, 1038.1f, 317.2f) + curveTo(1037.6f, 319.8f, 1037.1f, 322.5f, 1036.6f, 325.1f) + curveTo(1036.4f, 326.5f, 1036.4f, 326.5f, 1036.1f, 327.9f) + curveTo(1023.6f, 394.5f, 1005.7f, 459.8f, 983.7f, 523.9f) + curveTo(983f, 526f, 982.3f, 528.1f, 981.6f, 530.2f) + curveTo(966.6f, 574.1f, 948.9f, 617.1f, 929f, 659f) + curveTo(928.7f, 659.6f, 928.4f, 660.2f, 928.1f, 660.8f) + curveTo(919.9f, 678.2f, 911.5f, 695.3f, 902f, 712f) + curveTo(901.4f, 713f, 901.4f, 713f, 900.8f, 714.1f) + curveTo(895.4f, 723.6f, 895.4f, 723.6f, 894f, 725f) + curveTo(891.2f, 725.1f, 888.4f, 725.1f, 885.5f, 725.1f) + curveTo(884.6f, 725.1f, 883.8f, 725.1f, 882.8f, 725.1f) + curveTo(879.9f, 725.1f, 876.9f, 725.1f, 874f, 725.1f) + curveTo(872f, 725.1f, 869.9f, 725.1f, 867.9f, 725.1f) + curveTo(862.5f, 725.1f, 857.1f, 725.1f, 851.7f, 725.1f) + curveTo(846.3f, 725.1f, 840.8f, 725.1f, 835.3f, 725f) + curveTo(824.5f, 725f, 813.8f, 725f, 803f, 725f) + curveTo(797.5f, 715.3f, 792.1f, 705.5f, 787.1f, 695.6f) + curveTo(786.6f, 694.6f, 786.1f, 693.6f, 785.6f, 692.6f) + curveTo(784f, 689.4f, 782.4f, 686.3f, 780.9f, 683.1f) + curveTo(780.3f, 682f, 779.8f, 680.9f, 779.2f, 679.8f) + curveTo(769.9f, 661.1f, 761.1f, 642.3f, 752.9f, 623.1f) + curveTo(751.7f, 620.3f, 750.5f, 617.6f, 749.3f, 614.9f) + curveTo(713.8f, 533.2f, 687f, 447.6f, 659.4f, 314.6f) + curveTo(659.3f, 313.9f, 659.2f, 313.2f, 659f, 312.4f) + curveTo(657f, 300.9f, 657f, 300.9f, 657f, 295f) + close() + } + path(fill = SolidColor(Color(0xFF76C72E))) { + moveTo(534f, 295f) + curveTo(560.7f, 295f, 587.5f, 295f, 615f, 295f) + curveTo(615f, 436.9f, 615f, 578.8f, 615f, 725f) + curveTo(588.3f, 725f, 561.5f, 725f, 534f, 725f) + curveTo(534f, 583.1f, 534f, 441.2f, 534f, 295f) + close() + } + }.build() + + return _KivraLogo1920X1080Green!! + } + +@Suppress("ObjectPropertyName") +private var _KivraLogo1920X1080Green: ImageVector? = null + +@Preview +@Composable +private fun IconPreview() { + HedvigTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + imageVector = HedvigIcons.Kivra, + contentDescription = com.hedvig.android.compose.ui.EmptyContentDescription, + modifier = Modifier + .width((24.0).dp) + .height((24.0).dp), + ) + } + } +} diff --git a/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Swish.kt b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Swish.kt new file mode 100644 index 0000000000..4a892f0ca3 --- /dev/null +++ b/app/design-system/design-system-hedvig/src/commonMain/kotlin/com/hedvig/android/design/system/hedvig/icon/colored/Swish.kt @@ -0,0 +1,139 @@ +package com.hedvig.android.design.system.hedvig.icon.colored + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.PathFillType +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons + +val HedvigIcons.Swish: ImageVector + get() { + if (_Swish != null) { + return _Swish!! + } + _Swish = ImageVector.Builder( + name = "Swish", + defaultWidth = 420.dp, + defaultHeight = 420.dp, + viewportWidth = 420f, + viewportHeight = 420f, + ).apply { + path( + fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to Color(0xFFEF2131), + 1f to Color(0xFFFECF2C), + ), + start = Offset(237.8f, 289.7f), + end = Offset(177.74f, 104.45f), + ), + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(119.3f, 399.2f) + curveToRelative(84.3f, 40.3f, 188.3f, 20.4f, 251.2f, -54.5f) + curveToRelative(74.5f, -88.8f, 62.9f, -221.1f, -25.8f, -295.5f) + lineToRelative(-59f, 70.3f) + curveToRelative(69.3f, 58.2f, 78.4f, 161.5f, 20.2f, 230.9f) + curveToRelative(-46.4f, 55.3f, -122.8f, 73.7f, -186.5f, 48.9f) + } + path( + fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to Color(0xFFFBC52C), + 0.3f to Color(0xFFF87130), + 0.6f to Color(0xFFEF52E2), + 1f to Color(0xFF661EEC), + ), + start = Offset(379.9f, 129.59f), + end = Offset(243f, 399.77f), + ), + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(119.3f, 399.2f) + curveToRelative(84.3f, 40.3f, 188.3f, 20.4f, 251.2f, -54.5f) + curveToRelative(7.7f, -9.2f, 14.5f, -18.8f, 20.3f, -28.8f) + curveToRelative(9.9f, -61.7f, -11.9f, -126.9f, -63.2f, -169.9f) + curveToRelative(-13f, -10.9f, -27.2f, -19.8f, -41.9f, -26.5f) + curveToRelative(69.3f, 58.2f, 78.4f, 161.5f, 20.2f, 230.9f) + curveToRelative(-46.4f, 55.3f, -122.8f, 73.7f, -186.5f, 48.9f) + } + path( + fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to Color(0xFF78F6D8), + 0.3f to Color(0xFF77D1F6), + 0.6f to Color(0xFF70A4F3), + 1f to Color(0xFF661EEC), + ), + start = Offset(118.21f, 92.5f), + end = Offset(178.27f, 277.75f), + ), + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(300.3f, 20.4f) + curveTo(216f, -19.9f, 111.9f, 0f, 49.1f, 74.9f) + curveToRelative(-74.5f, 88.8f, -62.9f, 221.1f, 25.8f, 295.5f) + lineToRelative(59f, -70.3f) + curveToRelative(-69.3f, -58.2f, -78.4f, -161.5f, -20.2f, -230.9f) + curveTo(160.2f, 14f, 236.6f, -4.5f, 300.3f, 20.4f) + } + path( + fill = Brush.linearGradient( + colorStops = arrayOf( + 0f to Color(0xFF536EED), + 0.2f to Color(0xFF54C3EC), + 0.6f to Color(0xFF64D769), + 1f to Color(0xFFFECF2C), + ), + start = Offset(95.13f, 220.03f), + end = Offset(232.03f, -50.15f), + ), + pathFillType = PathFillType.EvenOdd, + ) { + moveTo(300.3f, 20.4f) + curveTo(216f, -19.9f, 111.9f, 0f, 49.1f, 74.9f) + curveToRelative(-7.7f, 9.2f, -14.5f, 18.8f, -20.3f, 28.8f) + curveToRelative(-9.9f, 61.7f, 11.9f, 126.9f, 63.2f, 169.9f) + curveToRelative(13f, 10.9f, 27.2f, 19.8f, 41.9f, 26.5f) + curveToRelative(-69.3f, -58.2f, -78.4f, -161.5f, -20.2f, -230.9f) + curveTo(160.2f, 14f, 236.6f, -4.5f, 300.3f, 20.4f) + } + }.build() + + return _Swish!! + } + +@Preview +@Composable +private fun IconPreview() { + HedvigTheme { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + imageVector = HedvigIcons.Swish, + contentDescription = com.hedvig.android.compose.ui.EmptyContentDescription, + modifier = Modifier + .width((24.0).dp) + .height((24.0).dp), + ) + } + } +} + +@Suppress("ObjectPropertyName") +private var _Swish: ImageVector? = null diff --git a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/TrustlyDeepLinks.kt b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/TrustlyDeepLinks.kt index b224953b23..cbb1c6d30a 100644 --- a/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/TrustlyDeepLinks.kt +++ b/app/feature/feature-connect-payment-trustly/src/main/kotlin/com/hedvig/android/feature/connect/payment/trustly/navigation/TrustlyDeepLinks.kt @@ -16,5 +16,5 @@ internal class TrustlyDeepLinkMatcherProvider( private val container: HedvigDeepLinkContainer, ) : DeepLinkMatcherProvider { override fun matchers(): List> = - uriDeepLinkMatchers(container.connectPayment + container.directDebit, TrustlyKey.serializer()) + uriDeepLinkMatchers(container.directDebit, TrustlyKey.serializer()) } diff --git a/app/feature/feature-onboarding/build.gradle.kts b/app/feature/feature-onboarding/build.gradle.kts index 1242e3ea4b..826b509d26 100644 --- a/app/feature/feature-onboarding/build.gradle.kts +++ b/app/feature/feature-onboarding/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation(projects.featureConnectPaymentTrustlyNavigation) implementation(projects.featureEditCoinsuredNavigation) implementation(projects.featureFlags) + implementation(projects.featurePayinAccountNavigation) implementation(projects.foreverUi) implementation(projects.moleculePublic) implementation(projects.navigationCommon) diff --git a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql index bf7a1a6e09..fabae8fe86 100644 --- a/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql +++ b/app/feature/feature-onboarding/src/main/graphql/OnboardingQuery.graphql @@ -36,6 +36,11 @@ query Onboarding { paymentMethods { payinMethods { status + isDefault + } + availableMethods { + provider + supportsPayin } } crossSellV2(input: { userFlow: ONBOARDING, flowSource: null, experiments: [] }) { diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt index 073b8a9b93..5bea5d9baa 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingData.kt @@ -12,6 +12,7 @@ internal data class OnboardingData( val contracts: List, val referralInformation: OnboardingReferralInformation?, val payinStatus: OnboardingPayinStatus, + val availablePayinProviders: List, val crossSells: List, ) { /** Contracts missing co-insured OR co-owner info, i.e. anything the co-insured step should show. */ @@ -32,6 +33,15 @@ internal data class OnboardingData( val hasConnectedPayinMethod: Boolean = payinStatus != OnboardingPayinStatus.NeedsSetup } +/** + * The payin providers onboarding can connect. Only these two are offered during onboarding, so a + * provider the backend adds later is left out until this step knows how to hand off to it. + */ +internal enum class OnboardingPayinProvider { + Trustly, + Swish, +} + /** Mirrors the backend `MemberPaymentConnectionStatus`. */ internal enum class OnboardingPayinStatus { Active, diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt index 078dbe1d45..435f92f189 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingRepository.kt @@ -10,10 +10,12 @@ import com.hedvig.android.apollo.ErrorMessage import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import octopus.OnboardingQuery import octopus.OnboardingUpdateContactInfoMutation +import octopus.type.MemberPaymentProvider internal interface OnboardingRepository { suspend fun getOnboardingData(): Either @@ -84,17 +86,24 @@ internal class OnboardingRepositoryImpl( currencyCode = referralInformation.monthlyDiscountPerReferral.currencyCode.rawValue, ) }, - payinStatus = member.paymentMethods.payinMethods.map { it.status.rawValue }.let { statuses -> + payinStatus = member.paymentMethods.payinMethods.let { methods -> + logcat { "Mariia: methods $methods " } when { - statuses.any { it == "ACTIVE" } -> OnboardingPayinStatus.Active + methods.any { it.status.rawValue == "ACTIVE" && it.isDefault } -> OnboardingPayinStatus.Active // A PENDING method counts as "connected enough" to skip the step (bank activation takes // days), but the step UI still shows it as pending rather than claiming it is connected. - statuses.any { it == "PENDING" } -> OnboardingPayinStatus.Pending + methods.any { it.status.rawValue == "PENDING" } -> OnboardingPayinStatus.Pending else -> OnboardingPayinStatus.NeedsSetup } }, + availablePayinProviders = member.paymentMethods.availableMethods + .filter { it.supportsPayin } + .mapNotNull { it.provider.toOnboardingPayinProvider() } + // TODO: the backend does not list Swish or Trustly as available payin methods yet, so both + // are offered unconditionally. Delete this fallback once it does. + .ifEmpty { listOf(OnboardingPayinProvider.Trustly, OnboardingPayinProvider.Swish) }, crossSells = member.crossSellV2.otherCrossSells.map { crossSell -> OnboardingCrossSell( id = crossSell.id, @@ -117,3 +126,9 @@ internal class OnboardingRepositoryImpl( ensure(userError == null) { ErrorMessage(userError?.message) } } } + +private fun MemberPaymentProvider.toOnboardingPayinProvider(): OnboardingPayinProvider? = when (this) { + MemberPaymentProvider.TRUSTLY -> OnboardingPayinProvider.Trustly + MemberPaymentProvider.SWISH -> OnboardingPayinProvider.Swish + else -> null +} diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt index 8bfb869449..eb67edd880 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/navigation/OnboardingNavigator.kt @@ -5,7 +5,9 @@ import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore +import com.hedvig.android.feature.payin.account.navigation.SetupSwishPayinKey import com.hedvig.android.logger.logcat import com.hedvig.android.navigation.compose.Backstack import com.hedvig.android.navigation.compose.add @@ -66,9 +68,17 @@ internal class OnboardingNavigator( backstack.add(CoInsuredAddInfoKey(contractId, type)) } - /** Pushes the Trustly connect-payment flow; it pops itself back here when done. */ - fun openConnectPayment() { - backstack.add(TrustlyKey) + /** + * Pushes the setup screen for the provider the member picked on the connect-payment step. Each + * screen pops itself when it is done, landing back on the step, which re-reads the live payin + * status on resume. + */ + fun openPayinSetup(provider: OnboardingPayinProvider) { + val key = when (provider) { + OnboardingPayinProvider.Trustly -> TrustlyKey + OnboardingPayinProvider.Swish -> SetupSwishPayinKey + } + backstack.add(key) } /** Pushes the shared Forever screen; the member leaves it with system back. */ diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt index 2ef573f64a..69b6f6c209 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingConnectingPaymentSymbol.kt @@ -4,66 +4,47 @@ import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween -import androidx.compose.foundation.Image -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.drawWithContent -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.PathEffect -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.graphics.drawOutline -import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalInspectionMode -import androidx.compose.ui.unit.Dp +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import com.hedvig.android.design.system.hedvig.HedvigPreview -import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.PaymentMethodHandoverIllustration +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodTileBadge import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.ThreeDotsLoading -import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.Checkmark import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.tokens.MotionTokens -import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL -import hedvig.resources.Res -import hedvig.resources.pillow_new_680 +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import kotlin.time.Duration.Companion.milliseconds import kotlinx.coroutines.delay -import org.jetbrains.compose.resources.painterResource -import org.jetbrains.compose.resources.stringResource - -private val SymbolSize = 74.dp -private val CheckBadgeSize = 24.dp // How long after the graphic appears the connected check pops in. private const val CheckPopDelayMillis = 400L /** - * The graphic on the connect-payment step: a "Bank" card linked by pulsing dots to the Hedvig - * symbol. It shows from the start; while [showCheck] is true (a payment method is connected) a green - * checkmark pops onto the symbol. [showCheck] tracks the live status directly, so the check appears - * only while the backend actually reports a connected method. The dots pulse continuously. + * The graphic on the connect-payment step: the method the member picked, linked by pulsing dots to + * the Hedvig symbol. While [showCheck] is true (a payment method is connected) a green checkmark + * pops onto the symbol. [showCheck] tracks the live status directly, so the check appears only while + * the backend actually reports a connected method. */ @Composable -internal fun OnboardingConnectingPaymentSymbol(showCheck: Boolean, modifier: Modifier = Modifier) { +internal fun OnboardingConnectingPaymentSymbol( + provider: OnboardingPayinProvider?, + showCheck: Boolean, + modifier: Modifier = Modifier, +) { // In a static preview the pop's delay never elapses, so seed the check as already shown there to // keep the connected states visible; at runtime it starts hidden and pops in. val inPreview = LocalInspectionMode.current @@ -76,63 +57,15 @@ internal fun OnboardingConnectingPaymentSymbol(showCheck: Boolean, modifier: Mod delay(CheckPopDelayMillis.milliseconds) checkVisible = true } - ConnectingGraphic(checkVisible = checkVisible, modifier = modifier) -} - -@Composable -private fun ConnectingGraphic(checkVisible: Boolean, modifier: Modifier = Modifier) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(16.dp), - ) { - BankCard() - ThreeDotsLoading() - HedvigSymbolWithCheck(checkVisible = checkVisible) - } -} - -@Composable -private fun BankCard() { - Surface( - shape = HedvigTheme.shapes.cornerXXLarge, - color = HedvigTheme.colorScheme.backgroundPrimary, - modifier = Modifier - .size(SymbolSize) - .hedvigDropShadow(HedvigTheme.shapes.cornerXXLarge) - .dashedBorder( - color = HedvigTheme.colorScheme.borderSecondary, - shape = HedvigTheme.shapes.cornerXXLarge, - strokeWidth = 1.dp, - dashOn = 4.dp, - dashOff = 4.dp, - ), - ) { - Box(contentAlignment = Alignment.Center, modifier = Modifier.fillMaxSize()) { - HedvigText( - text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL), - style = HedvigTheme.typography.bodySmall.copy(fontFamily = HedvigTheme.typography.serif), - ) - } - } + ConnectingGraphic(provider = provider, checkVisible = checkVisible, modifier = modifier) } -/** Draws a dashed outline that follows [shape], on top of the content so it is not clipped. */ -private fun Modifier.dashedBorder(color: Color, shape: Shape, strokeWidth: Dp, dashOn: Dp, dashOff: Dp): Modifier = - drawWithContent { - drawContent() - drawOutline( - outline = shape.createOutline(size, layoutDirection, this), - color = color, - style = Stroke( - width = strokeWidth.toPx(), - pathEffect = PathEffect.dashPathEffect(floatArrayOf(dashOn.toPx(), dashOff.toPx())), - ), - ) - } - @Composable -private fun HedvigSymbolWithCheck(checkVisible: Boolean) { +private fun ConnectingGraphic( + provider: OnboardingPayinProvider?, + checkVisible: Boolean, + modifier: Modifier = Modifier, +) { val checkScale by animateFloatAsState( targetValue = if (checkVisible) 1f else 0f, animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessMediumLow), @@ -143,52 +76,45 @@ private fun HedvigSymbolWithCheck(checkVisible: Boolean) { animationSpec = tween(MotionTokens.DurationShort4.toInt(), easing = MotionTokens.EasingStandardCubicBezier), label = "connected check alpha", ) - Box { - Image( - painter = painterResource(Res.drawable.pillow_new_680), - contentDescription = null, - modifier = Modifier.size(SymbolSize), - ) - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = 6.dp, y = (-6).dp) - .size(CheckBadgeSize) - .graphicsLayer { + PaymentMethodHandoverIllustration( + modifier = modifier, + destinationBadge = { + PaymentMethodTileBadge( + icon = HedvigIcons.Checkmark, + containerColor = HedvigTheme.colorScheme.signalGreenElement, + contentColor = HedvigTheme.colorScheme.fillWhite, + modifier = Modifier.graphicsLayer { scaleX = checkScale scaleY = checkScale alpha = checkAlpha - } - .clip(CircleShape) - .background(HedvigTheme.colorScheme.signalGreenElement), - ) { - Icon( - imageVector = HedvigIcons.Checkmark, - contentDescription = null, - tint = HedvigTheme.colorScheme.fillWhite, - modifier = Modifier.size(16.dp), + }, ) - } - } + }, + mark = { OnboardingPayinProviderMark(provider, Modifier.size(PaymentMethodMarkSize)) }, + ) } @HedvigPreview @Composable -private fun PreviewOnboardingConnectingPaymentSymbolConnected() { +private fun PreviewOnboardingConnectingPaymentSymbol( + @PreviewParameter(ConnectingSymbolProvider::class) provider: OnboardingPayinProvider?, +) { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { - ConnectingGraphic(checkVisible = true, modifier = Modifier.size(width = 210.dp, height = 74.dp)) + ConnectingGraphic( + provider = provider, + checkVisible = provider != null, + modifier = Modifier.size(width = 210.dp, height = 74.dp), + ) } } } -@HedvigPreview -@Composable -private fun PreviewOnboardingConnectingPaymentSymbolInitial() { - HedvigTheme { - Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { - ConnectingGraphic(checkVisible = false, modifier = Modifier.size(width = 210.dp, height = 74.dp)) - } - } -} +private class ConnectingSymbolProvider : + CollectionPreviewParameterProvider( + listOf( + null, + OnboardingPayinProvider.Trustly, + OnboardingPayinProvider.Swish, + ), + ) diff --git a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt index c59f4677ef..a91f209cb8 100644 --- a/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt +++ b/app/feature/feature-onboarding/src/main/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentDestination.kt @@ -1,7 +1,11 @@ package com.hedvig.android.feature.onboarding.ui.payment +import androidx.compose.foundation.Image 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.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue @@ -12,18 +16,32 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.EmptyContentDescription import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.PaymentMethodPillow +import com.hedvig.android.design.system.hedvig.PaymentMethodPillowMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodPlusMark +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Trustly +import com.hedvig.android.design.system.hedvig.icon.colored.Swish +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingSessionStore import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator @@ -38,13 +56,17 @@ import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject -import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SUBTITLE import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_TITLE import hedvig.resources.ONBOARDING_DO_THIS_LATER_BUTTON +import hedvig.resources.PAYMENT_CONNECT_TITLE +import hedvig.resources.PAYMENT_OPTION_SWISH_SUBTITLE +import hedvig.resources.PAYMENT_OPTION_TRUSTLY_SUBTITLE import hedvig.resources.Res import hedvig.resources.general_continue_button +import hedvig.resources.swish import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -70,6 +92,7 @@ internal class OnboardingPaymentPresenter( var currentState by remember { mutableStateOf(lastState) } var loadIteration by remember { mutableIntStateOf(0) } var hasAttemptedToConnect by remember { mutableStateOf(false) } + var selectedProvider by remember { mutableStateOf(null) } LaunchedEffect(loadIteration) { if (currentState is OnboardingPaymentUiState.Content) return@LaunchedEffect @@ -80,6 +103,7 @@ internal class OnboardingPaymentPresenter( currentState = OnboardingPaymentUiState.Content( progress = session.progressFor(OnboardingStepId.ConnectPayment), payinStatus = session.data.payinStatus, + availableProviders = session.data.availablePayinProviders, ) }, ) @@ -101,14 +125,22 @@ internal class OnboardingPaymentPresenter( currentState = OnboardingPaymentUiState.Content( progress = refreshed.progressFor(OnboardingStepId.ConnectPayment), payinStatus = refreshed.data.payinStatus, + availableProviders = refreshed.data.availablePayinProviders, ) } } } + is OnboardingPaymentEvent.SelectProvider -> { + selectedProvider = event.provider + } + OnboardingPaymentEvent.ConnectPayment -> { - hasAttemptedToConnect = true - navigator.openConnectPayment() + val provider = selectedProvider + if (provider != null) { + hasAttemptedToConnect = true + navigator.openPayinSetup(provider) + } } OnboardingPaymentEvent.Continue -> { @@ -118,7 +150,11 @@ internal class OnboardingPaymentPresenter( } return when (val state = currentState) { - is OnboardingPaymentUiState.Content -> state.copy(hasAttemptedToConnect = hasAttemptedToConnect) + is OnboardingPaymentUiState.Content -> state.copy( + hasAttemptedToConnect = hasAttemptedToConnect, + selectedProvider = selectedProvider, + ) + else -> state } } @@ -132,6 +168,8 @@ internal sealed interface OnboardingPaymentUiState { data class Content( val progress: OnboardingProgress, val payinStatus: OnboardingPayinStatus, + val availableProviders: List, + val selectedProvider: OnboardingPayinProvider? = null, // The skip is offered only once the member has entered the connect flow at least once, so a // member who tries and does not finish is never stuck on this step. val hasAttemptedToConnect: Boolean = false, @@ -145,6 +183,8 @@ internal sealed interface OnboardingPaymentEvent { data object Refresh : OnboardingPaymentEvent + data class SelectProvider(val provider: OnboardingPayinProvider) : OnboardingPaymentEvent + data object ConnectPayment : OnboardingPaymentEvent data object Continue : OnboardingPaymentEvent @@ -170,6 +210,7 @@ internal fun OnboardingPaymentDestination(viewModel: OnboardingPaymentViewModel, navigateUp = navigateUp, onClose = { viewModel.emit(OnboardingPaymentEvent.Close) }, onRetry = { viewModel.emit(OnboardingPaymentEvent.Retry) }, + onProviderSelected = { viewModel.emit(OnboardingPaymentEvent.SelectProvider(it)) }, onConnectPayment = { viewModel.emit(OnboardingPaymentEvent.ConnectPayment) }, onContinue = { viewModel.emit(OnboardingPaymentEvent.Continue) }, ) @@ -182,6 +223,7 @@ private fun OnboardingPaymentScreen( navigateUp: () -> Unit, onClose: () -> Unit, onRetry: () -> Unit, + onProviderSelected: (OnboardingPayinProvider) -> Unit, onConnectPayment: () -> Unit, onContinue: () -> Unit, ) { @@ -219,23 +261,51 @@ private fun OnboardingPaymentScreen( ) Spacer(Modifier.weight(1f)) OnboardingConnectingPaymentSymbol( + provider = content.selectedProvider, showCheck = isConnected, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(Modifier.weight(1f)) + if (!isConnected) { + HedvigText( + // TODO: Add "Required to keep your insurance active" / + // "Krävs för att din försäkring ska vara aktiv" to Lokalise + text = "Required to keep your insurance active", + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondaryTranslucent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + RadioGroup( + options = content.availableProviders.map { it.toRadioOption() }, + selectedOption = content.selectedProvider?.let { RadioOptionId(it.name) }, + onRadioOptionSelected = { id -> + val provider = content.availableProviders.firstOrNull { it.name == id.id } + if (provider != null) onProviderSelected(provider) + }, + optionIcon = { id -> + OnboardingPayinProviderPillow(content.availableProviders.firstOrNull { it.name == id.id }) + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } OnboardingStepButtons( primaryText = if (isConnected) { stringResource(Res.string.general_continue_button) } else { - stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_TITLE) + stringResource(Res.string.PAYMENT_CONNECT_TITLE) }, onPrimaryClick = if (isConnected) onContinue else onConnectPayment, + primaryEnabled = isConnected || content.selectedProvider != null, secondaryText = if (canSkip) stringResource(Res.string.ONBOARDING_DO_THIS_LATER_BUTTON) else null, onSecondaryClick = if (canSkip) onContinue else null, caption = if (isConnected) { stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_SWITCH_ACCOUNTS_LATER) } else { - stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_FOOTNOTE) + null }, ) } @@ -243,6 +313,44 @@ private fun OnboardingPaymentScreen( } } +@Composable +private fun OnboardingPayinProvider.toRadioOption(): RadioOption = when (this) { + OnboardingPayinProvider.Trustly -> RadioOption( + id = RadioOptionId(name), + text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL), + label = stringResource(Res.string.PAYMENT_OPTION_TRUSTLY_SUBTITLE), + ) + + OnboardingPayinProvider.Swish -> RadioOption( + id = RadioOptionId(name), + text = stringResource(Res.string.swish), + label = stringResource(Res.string.PAYMENT_OPTION_SWISH_SUBTITLE), + ) +} + +/** + * The provider's brand mark. Trustly's is monochrome and picks up the surrounding content colour; + * Swish's is full-colour and ignores it. + */ +@Composable +internal fun OnboardingPayinProviderMark(provider: OnboardingPayinProvider?, modifier: Modifier = Modifier) { + when (provider) { + OnboardingPayinProvider.Trustly -> Icon(HedvigIcons.Trustly, EmptyContentDescription, modifier) + OnboardingPayinProvider.Swish -> Image(HedvigIcons.Swish, EmptyContentDescription, modifier) + null -> PaymentMethodPlusMark(modifier) + } +} + +@Composable +private fun OnboardingPayinProviderPillow(provider: OnboardingPayinProvider?) { + val onDarkTile = provider == OnboardingPayinProvider.Trustly + PaymentMethodPillow( + containerColor = if (onDarkTile) HedvigTheme.colorScheme.fillBlack else HedvigTheme.colorScheme.fillWhite, + contentColor = if (onDarkTile) HedvigTheme.colorScheme.fillWhite else HedvigTheme.colorScheme.fillBlack, + mark = { OnboardingPayinProviderMark(provider, Modifier.size(PaymentMethodPillowMarkSize)) }, + ) +} + @HedvigPreview @Composable private fun PreviewOnboardingPaymentScreen( @@ -256,6 +364,7 @@ private fun PreviewOnboardingPaymentScreen( navigateUp = {}, onClose = {}, onRetry = {}, + onProviderSelected = {}, onConnectPayment = {}, onContinue = {}, ) @@ -270,19 +379,24 @@ private class OnboardingPaymentUiStateProvider : CollectionPreviewParameterProvi OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.NeedsSetup, + availableProviders = OnboardingPayinProvider.entries, ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.NeedsSetup, + availableProviders = OnboardingPayinProvider.entries, + selectedProvider = OnboardingPayinProvider.Swish, hasAttemptedToConnect = true, ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Pending, + availableProviders = OnboardingPayinProvider.entries, ), OnboardingPaymentUiState.Content( progress = OnboardingProgress(totalSteps = 5, currentIndex = 3), payinStatus = OnboardingPayinStatus.Active, + availableProviders = OnboardingPayinProvider.entries, ), ), ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt index f21013aeb7..8451322611 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/FakeOnboardingRepository.kt @@ -9,6 +9,7 @@ import com.hedvig.android.feature.onboarding.data.OnboardingContract import com.hedvig.android.feature.onboarding.data.OnboardingCrossSell import com.hedvig.android.feature.onboarding.data.OnboardingData import com.hedvig.android.feature.onboarding.data.OnboardingMemberIdProvider +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.data.OnboardingReferralInformation import com.hedvig.android.feature.onboarding.data.OnboardingRepository @@ -81,5 +82,6 @@ internal fun testOnboardingData( contracts = contracts, referralInformation = referralInformation, payinStatus = payinStatus, + availablePayinProviders = OnboardingPayinProvider.entries, crossSells = crossSells, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt index c12dfca2f5..8d9ffe212f 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/data/OnboardingPathTest.kt @@ -6,6 +6,7 @@ import assertk.assertions.containsExactly import assertk.assertions.doesNotContain import assertk.assertions.hasSize import assertk.assertions.isEqualTo +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import org.junit.Test @@ -47,6 +48,7 @@ class OnboardingPathTest { contracts = contracts, referralInformation = referralInformation, payinStatus = if (hasConnectedPayinMethod) OnboardingPayinStatus.Active else OnboardingPayinStatus.NeedsSetup, + availablePayinProviders = OnboardingPayinProvider.entries, crossSells = crossSells, ) diff --git a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt index 0a16a21729..9a95caaf71 100644 --- a/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt +++ b/app/feature/feature-onboarding/src/test/kotlin/com/hedvig/android/feature/onboarding/ui/payment/OnboardingPaymentPresenterTest.kt @@ -3,20 +3,24 @@ package com.hedvig.android.feature.onboarding.ui.payment import androidx.compose.runtime.mutableStateListOf import arrow.core.right import assertk.assertThat +import assertk.assertions.containsExactly import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isInstanceOf +import assertk.assertions.isNull import assertk.assertions.isTrue import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.onboarding.FakeOnboardingMemberIdProvider import com.hedvig.android.feature.onboarding.FakeOnboardingRepository import com.hedvig.android.feature.onboarding.data.CompleteOnboardingUseCase +import com.hedvig.android.feature.onboarding.data.OnboardingPayinProvider import com.hedvig.android.feature.onboarding.data.OnboardingPayinStatus import com.hedvig.android.feature.onboarding.navigation.OnboardingNavigator import com.hedvig.android.feature.onboarding.navigation.OnboardingStepId import com.hedvig.android.feature.onboarding.navigation.OnboardingStepKey import com.hedvig.android.feature.onboarding.testOnboardingData import com.hedvig.android.feature.onboarding.testSessionStore +import com.hedvig.android.feature.payin.account.navigation.SetupSwishPayinKey import com.hedvig.android.logger.TestLogcatLoggingRule import com.hedvig.android.molecule.test.test import com.hedvig.android.navigation.common.HedvigNavKey @@ -57,7 +61,7 @@ internal class OnboardingPaymentPresenterTest { } @Test - fun `connect payment pushes the trustly flow`() = runTest { + fun `connect payment pushes the swish setup screen when swish is selected`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } val repository = FakeOnboardingRepository() val sessionStore = testSessionStore(repository, FakeOnboardingMemberIdProvider()) @@ -68,6 +72,29 @@ internal class OnboardingPaymentPresenterTest { skipItems(1) repository.onboardingDataResponses.add(testOnboardingData().right()) awaitItem() + sendEvent(OnboardingPaymentEvent.SelectProvider(OnboardingPayinProvider.Swish)) + awaitItem() + sendEvent(OnboardingPaymentEvent.ConnectPayment) + awaitItem() + runCurrent() + assertThat(backstack.entries.last()).isEqualTo(SetupSwishPayinKey) + } + } + + @Test + fun `connect payment pushes trustly when the bank option is selected`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = testSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + awaitItem() + sendEvent(OnboardingPaymentEvent.SelectProvider(OnboardingPayinProvider.Trustly)) + awaitItem() sendEvent(OnboardingPaymentEvent.ConnectPayment) awaitItem() runCurrent() @@ -75,6 +102,25 @@ internal class OnboardingPaymentPresenterTest { } } + @Test + fun `connect payment does nothing while no provider is selected`() = runTest { + val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } + val repository = FakeOnboardingRepository() + val sessionStore = testSessionStore(repository, FakeOnboardingMemberIdProvider()) + val navigator = OnboardingNavigator(backstack, sessionStore, NoopCompleteOnboardingUseCase()) + val presenter = OnboardingPaymentPresenter(sessionStore, navigator) + + presenter.test(OnboardingPaymentUiState.Loading) { + skipItems(1) + repository.onboardingDataResponses.add(testOnboardingData().right()) + val content = awaitItem() as OnboardingPaymentUiState.Content + assertThat(content.selectedProvider).isNull() + sendEvent(OnboardingPaymentEvent.ConnectPayment) + runCurrent() + assertThat(backstack.entries).containsExactly(OnboardingStepKey(OnboardingStepId.ConnectPayment)) + } + } + @Test fun `skip is offered only after entering the connect flow while still not connected`() = runTest { val backstack = TestBackstack().apply { entries.add(OnboardingStepKey(OnboardingStepId.ConnectPayment)) } @@ -89,6 +135,8 @@ internal class OnboardingPaymentPresenterTest { val initial = awaitItem() as OnboardingPaymentUiState.Content assertThat(initial.hasAttemptedToConnect).isFalse() + sendEvent(OnboardingPaymentEvent.SelectProvider(OnboardingPayinProvider.Swish)) + awaitItem() sendEvent(OnboardingPaymentEvent.ConnectPayment) val afterAttempt = awaitItem() as OnboardingPaymentUiState.Content assertThat(afterAttempt.hasAttemptedToConnect).isTrue() diff --git a/app/feature/feature-payin-account-navigation/build.gradle.kts b/app/feature/feature-payin-account-navigation/build.gradle.kts new file mode 100644 index 0000000000..c2442b5824 --- /dev/null +++ b/app/feature/feature-payin-account-navigation/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("hedvig.multiplatform.library") + id("hedvig.multiplatform.library.android") + id("hedvig.gradle.plugin") +} + +hedvig { + serialization() + navKeys() +} + +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + implementation(projects.coreCommonPublic) + implementation(projects.navigationCommon) + } + androidMain.dependencies { + implementation(projects.dataPayingMember) + } + } +} diff --git a/app/feature/feature-payin-account-navigation/src/androidMain/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKey.kt b/app/feature/feature-payin-account-navigation/src/androidMain/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKey.kt new file mode 100644 index 0000000000..f3e901a77e --- /dev/null +++ b/app/feature/feature-payin-account-navigation/src/androidMain/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKey.kt @@ -0,0 +1,14 @@ +package com.hedvig.android.feature.payin.account.navigation + +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +/** + * The picker for which connected method the member is charged on. Android-only because it carries + * [PayinAccount], which comes from the JVM-only `:data-paying-member`. + */ +@Serializable +data class SelectPrimaryPayinMethodKey( + val currentMethods: List, +) : HedvigNavKey diff --git a/app/feature/feature-payin-account-navigation/src/commonMain/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountKey.kt b/app/feature/feature-payin-account-navigation/src/commonMain/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountKey.kt new file mode 100644 index 0000000000..6dd7b68e3f --- /dev/null +++ b/app/feature/feature-payin-account-navigation/src/commonMain/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountKey.kt @@ -0,0 +1,34 @@ +package com.hedvig.android.feature.payin.account.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +@Serializable +data object PayinAccountKey : HedvigNavKey + +/** + * Names one of the member's connected payin methods. A method is identified by its provider because a member can + * have at most one connected method per provider. + */ +@Serializable +enum class PayinMethodId { + Trustly, + Swish, + Invoice, +} + +@Serializable +data class PayinMethodDetailsKey( + val method: PayinMethodId, +) : HedvigNavKey + +/** The Swish payin setup screen, reachable both from the payin flow and from onboarding. */ +@Serializable +data object SetupSwishPayinKey : HedvigNavKey + +/** The picker for connecting a new payin method, seeded with what the member can and already has. */ +@Serializable +data class SelectPayinMethodKey( + val availableProviders: List, + val currentProviders: List, +) : HedvigNavKey diff --git a/app/feature/feature-payin-account/build.gradle.kts b/app/feature/feature-payin-account/build.gradle.kts new file mode 100644 index 0000000000..65b787ecb9 --- /dev/null +++ b/app/feature/feature-payin-account/build.gradle.kts @@ -0,0 +1,38 @@ +plugins { + id("hedvig.android.library") + id("hedvig.gradle.plugin") +} + +hedvig { + apollo("octopus") + serialization() + compose() + navKeys() + viewModels() +} + +dependencies { + implementation(libs.apollo.normalizedCache) + implementation(libs.apollo.runtime) + implementation(libs.arrow.core) + implementation(projects.apolloCore) + implementation(projects.apolloNetworkCacheManager) + implementation(projects.apolloOctopusPublic) + implementation(projects.coreBuildConstants) + implementation(libs.jetbrains.compose.runtime) + implementation(libs.jetbrains.lifecycle.runtime.compose) + implementation(projects.composeUi) + implementation(projects.coreCommonPublic) + implementation(projects.coreResources) + implementation(projects.dataPayingMember) + implementation(projects.designSystemHedvig) + implementation(projects.featurePayinAccountNavigation) + implementation(projects.moleculePublic) + implementation(projects.navigationCommon) + implementation(projects.navigationCompose) + implementation(projects.navigationCore) + implementation(libs.zXing) + testImplementation(libs.assertK) + testImplementation(libs.kotlinx.serialization.json) + testImplementation(libs.junit) +} diff --git a/app/feature/feature-payin-account/src/main/AndroidManifest.xml b/app/feature/feature-payin-account/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..4a14d916af --- /dev/null +++ b/app/feature/feature-payin-account/src/main/AndroidManifest.xml @@ -0,0 +1,14 @@ + + + + + + + + + + diff --git a/app/feature/feature-payin-account/src/main/graphql/GetPayinMethods.graphql b/app/feature/feature-payin-account/src/main/graphql/GetPayinMethods.graphql new file mode 100644 index 0000000000..4ba076ac32 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/graphql/GetPayinMethods.graphql @@ -0,0 +1,15 @@ +query GetPayinMethods { + currentMember { + paymentMethods(version: 2) { + chargingDay + payinMethods { + ...MemberPaymentMethodFragment + } + availableMethods { + provider + supportsPayin + isActive + } + } + } +} diff --git a/app/feature/feature-payin-account/src/main/graphql/PaymentMethodSetupStatus.graphql b/app/feature/feature-payin-account/src/main/graphql/PaymentMethodSetupStatus.graphql new file mode 100644 index 0000000000..ead02da730 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/graphql/PaymentMethodSetupStatus.graphql @@ -0,0 +1,8 @@ +query PaymentMethodSetupStatus($orderId: ID!) { + paymentMethodSetupStatus(orderId: $orderId) { + status + error { + message + } + } +} diff --git a/app/feature/feature-payin-account/src/main/graphql/RemovePayinMethodMutation.graphql b/app/feature/feature-payin-account/src/main/graphql/RemovePayinMethodMutation.graphql new file mode 100644 index 0000000000..e7512b3ea5 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/graphql/RemovePayinMethodMutation.graphql @@ -0,0 +1,5 @@ +mutation RemovePayinMethod($provider: MemberPaymentProvider!) { + paymentMethodRemoveMethod(provider: $provider) { + message + } +} diff --git a/app/feature/feature-payin-account/src/main/graphql/SetAsDefaultMutation.graphql b/app/feature/feature-payin-account/src/main/graphql/SetAsDefaultMutation.graphql new file mode 100644 index 0000000000..7150867bb7 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/graphql/SetAsDefaultMutation.graphql @@ -0,0 +1,7 @@ +mutation SetAsDefaultPayin($provider: MemberPaymentProvider!) { + paymentMethodSetDefaultPayin( + provider: $provider + ) { + message + } +} diff --git a/app/feature/feature-payout-account/src/main/graphql/SetupInvoicePayout.graphql b/app/feature/feature-payin-account/src/main/graphql/SetupInvoicePayin.graphql similarity index 73% rename from app/feature/feature-payout-account/src/main/graphql/SetupInvoicePayout.graphql rename to app/feature/feature-payin-account/src/main/graphql/SetupInvoicePayin.graphql index dd61ac8dcf..cb935fbfd1 100644 --- a/app/feature/feature-payout-account/src/main/graphql/SetupInvoicePayout.graphql +++ b/app/feature/feature-payin-account/src/main/graphql/SetupInvoicePayin.graphql @@ -1,4 +1,4 @@ -mutation SetupInvoicePayout { +mutation SetupInvoicePayin { paymentMethodSetupInvoicePayin { status error { diff --git a/app/feature/feature-payin-account/src/main/graphql/SetupSwishPayinMutation.graphql b/app/feature/feature-payin-account/src/main/graphql/SetupSwishPayinMutation.graphql new file mode 100644 index 0000000000..4bdf9f3c25 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/graphql/SetupSwishPayinMutation.graphql @@ -0,0 +1,10 @@ +mutation SetupSwishPayin($input: PaymentMethodSetupSwishInput!) { + paymentMethodSetupSwishPayin(input: $input) { + error { + message + } + status + url + orderId + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetPayinAccountUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetPayinAccountUseCase.kt new file mode 100644 index 0000000000..bc4ccf577c --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetPayinAccountUseCase.kt @@ -0,0 +1,71 @@ +package com.hedvig.android.feature.payin.account.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.toPayinAccount +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.GetPayinMethodsQuery +import octopus.type.MemberPaymentProvider + +internal data class PayinAccountData( + val currentMethods: List, + val availablePayinMethods: List, + /** Day of the month the member is charged on. Set per member, so it is the same for every method. */ + val chargingDay: Int?, +) + +@SingleIn(AppScope::class) +@Inject +internal class GetPayinAccountUseCase( + private val apolloClient: ApolloClient, +) { + suspend fun invoke(): Either = either { + val result = apolloClient + .query(GetPayinMethodsQuery()) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute(::ErrorMessage) + .bind() + + val paymentMethods = result.currentMember.paymentMethods + + val currentMethods = paymentMethods.payinMethods.mapNotNull { it.toPayinAccount() } + val availablePayinMethods = paymentMethods.availableMethods + .filter { it.supportsPayin } + .map { it.provider } + PayinAccountData( + currentMethods = currentMethods, + availablePayinMethods = availablePayinMethods, + chargingDay = paymentMethods.chargingDay, + ) + } +} + +internal val PayinAccount.id: PayinMethodId + get() = when (this) { + is PayinAccount.Trustly -> PayinMethodId.Trustly + is PayinAccount.SwishPayin -> PayinMethodId.Swish + is PayinAccount.Invoice -> PayinMethodId.Invoice + } + +internal fun InvoiceDelivery?.toDeliveryString(): String? { + return when (this) { + // TODO: Add "Kivra" / "Kivra" to Lokalise + InvoiceDelivery.Kivra -> "Kivra" + + // TODO: Add "Email" / "E-post" to Lokalise + InvoiceDelivery.Mail -> "Email" + + null -> null + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetSwishPayinSetupStatusUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetSwishPayinSetupStatusUseCase.kt new file mode 100644 index 0000000000..7685864afc --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/GetSwishPayinSetupStatusUseCase.kt @@ -0,0 +1,68 @@ +package com.hedvig.android.feature.payin.account.data + +import arrow.core.Either +import arrow.core.raise.either +import com.apollographql.apollo.ApolloClient +import com.apollographql.cache.normalized.FetchPolicy +import com.apollographql.cache.normalized.fetchPolicy +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.NetworkCacheManager +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.PaymentMethodSetupStatusQuery +import octopus.type.PaymentMethodSetupStatus + +/** Where a setup order the member is approving in the Swish app has got to. */ +internal sealed interface SwishPayinSetupStatus { + data object Active : SwishPayinSetupStatus + + data object Pending : SwishPayinSetupStatus + + data class Failed(val message: String?) : SwishPayinSetupStatus +} + +internal interface GetSwishPayinSetupStatusUseCase { + suspend fun invoke(orderId: String): Either +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class GetSwishPayinSetupStatusUseCaseImpl( + private val apolloClient: ApolloClient, + private val networkCacheManager: NetworkCacheManager, +) : GetSwishPayinSetupStatusUseCase { + override suspend fun invoke(orderId: String): Either = either { + val output = apolloClient + .query(PaymentMethodSetupStatusQuery(orderId)) + .fetchPolicy(FetchPolicy.NetworkOnly) + .safeExecute(::ErrorMessage) + .bind() + .paymentMethodSetupStatus + + when (output?.status) { + PaymentMethodSetupStatus.ACTIVE -> { + // The new method is absent from every cached payment query until the next fetch. + networkCacheManager.clearCache() + SwishPayinSetupStatus.Active + } + + PaymentMethodSetupStatus.FAILED -> { + SwishPayinSetupStatus.Failed(output.error?.message) + } + + // A null output is an order the backend does not know about yet, which is indistinguishable + // from one still being processed, so both keep the caller waiting rather than failing it. + PaymentMethodSetupStatus.PENDING, + PaymentMethodSetupStatus.UNKNOWN__, + null, + -> { + SwishPayinSetupStatus.Pending + } + } + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/RemoveMethodUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/RemoveMethodUseCase.kt new file mode 100644 index 0000000000..99558e1d09 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/RemoveMethodUseCase.kt @@ -0,0 +1,55 @@ +package com.hedvig.android.feature.payin.account.data + +import arrow.core.Either +import arrow.core.raise.context.either +import arrow.core.raise.context.raise +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.NetworkCacheManager +import com.hedvig.android.apollo.safeExecuteAllowingPartialResponses +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.RemovePayinMethodMutation +import octopus.type.MemberPaymentProvider + +internal interface RemoveMethodUseCase { + suspend fun invoke(provider: MemberPaymentProvider): Either +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class RemoveMethodUseCaseImpl( + private val apolloClient: ApolloClient, + private val networkCacheManager: NetworkCacheManager, +) : RemoveMethodUseCase { + override suspend fun invoke(provider: MemberPaymentProvider): Either = either { + apolloClient + .mutation(RemovePayinMethodMutation(provider)) + .safeExecuteAllowingPartialResponses() + .fold( + fa = { error -> + logcat(LogPriority.ERROR) { "RemovePayinMethodMutation error: $error" } + raise(ErrorMessage()) + }, + fb = { result -> + val userError = result.paymentMethodRemoveMethod?.message + if (userError != null) { + logcat(LogPriority.ERROR) { "RemovePayinMethodMutation user error: $userError" } + raise(ErrorMessage(userError)) + } + // The removed method is still in every cached payment query until the next fetch. + networkCacheManager.clearCache() + }, + fab = { errors, _ -> + logcat(LogPriority.ERROR) { "RemovePayinMethodMutation data with errors: $errors" } + raise(ErrorMessage()) + }, + ) + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetAsDefaultUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetAsDefaultUseCase.kt new file mode 100644 index 0000000000..15a7f6c2e1 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetAsDefaultUseCase.kt @@ -0,0 +1,56 @@ +package com.hedvig.android.feature.payin.account.data + +import arrow.core.Either +import arrow.core.raise.context.bind +import arrow.core.raise.context.either +import arrow.core.raise.context.raise +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.safeExecute +import com.hedvig.android.apollo.safeExecuteAllowingPartialResponses +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.SetAsDefaultPayinMutation +import octopus.type.MemberPaymentProvider + +internal interface SetAsDefaultUseCase { + suspend fun invoke(provider: MemberPaymentProvider): Either +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class SetAsDefaultUseCaseImpl( + private val apolloClient: ApolloClient, + private val getPayinAccountUseCase: GetPayinAccountUseCase, +) : SetAsDefaultUseCase { + override suspend fun invoke(provider: MemberPaymentProvider): Either { + return either { + apolloClient + .mutation(SetAsDefaultPayinMutation(provider)) + .safeExecuteAllowingPartialResponses() + .fold( + fa = { error -> + logcat { "SetAsDefaultUseCaseImpl error: $error" } + raise(ErrorMessage()) + }, + fb = { result -> + val userError = result.paymentMethodSetDefaultPayin?.message + if (userError != null) { + logcat { "SetAsDefaultUseCaseImpl userError not null: $userError" } + raise(ErrorMessage(userError)) + } + getPayinAccountUseCase.invoke().bind() + }, + fab = { errors, _ -> + logcat { "SetAsDefaultUseCaseImpl data with errors: $errors" } + raise(ErrorMessage()) + }, + ) + } + } +} diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/SetupInvoicePayoutUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupInvoicePayinUseCase.kt similarity index 79% rename from app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/SetupInvoicePayoutUseCase.kt rename to app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupInvoicePayinUseCase.kt index 6d769eba62..c9b03eb91e 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/SetupInvoicePayoutUseCase.kt +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupInvoicePayinUseCase.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.payoutaccount.data +package com.hedvig.android.feature.payin.account.data import arrow.core.Either import arrow.core.raise.either @@ -10,26 +10,25 @@ import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn -import octopus.SetupInvoicePayoutMutation -import octopus.type.PaymentMethodInvoiceDelivery +import octopus.SetupInvoicePayinMutation import octopus.type.PaymentMethodSetupStatus @SingleIn(AppScope::class) @Inject -internal class SetupInvoicePayoutUseCase( +internal class SetupInvoicePayinUseCase( private val apolloClient: ApolloClient, private val networkCacheManager: NetworkCacheManager, ) { suspend fun invoke(): Either = either { val result = apolloClient - .mutation(SetupInvoicePayoutMutation()) + .mutation(SetupInvoicePayinMutation()) .safeExecute(::ErrorMessage) .bind() val output = result.paymentMethodSetupInvoicePayin when (output.status) { PaymentMethodSetupStatus.FAILED -> { - raise(ErrorMessage(output.error?.message ?: "Failed to set up invoice payout")) + raise(ErrorMessage(output.error?.message ?: "Failed to set up invoice payin")) } else -> { diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupSwishPayinUseCase.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupSwishPayinUseCase.kt new file mode 100644 index 0000000000..a79fc49f0c --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/data/SetupSwishPayinUseCase.kt @@ -0,0 +1,101 @@ +package com.hedvig.android.feature.payin.account.data + +import arrow.core.Either +import arrow.core.raise.context.either +import arrow.core.raise.context.raise +import com.apollographql.apollo.ApolloClient +import com.hedvig.android.apollo.ErrorMessage +import com.hedvig.android.apollo.NetworkCacheManager +import com.hedvig.android.apollo.safeExecuteAllowingPartialResponses +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.logger.logcat +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import octopus.SetupSwishPayinMutation +import octopus.type.PaymentMethodSetupStatus +import octopus.type.PaymentMethodSetupSwishInput + +internal interface SetupSwishPayinUseCase { + suspend fun invoke(phoneNumber: String): Either +} + +@ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) +@Inject +internal class SetupSwishPayinUseCaseImpl( + private val apolloClient: ApolloClient, + private val networkCacheManager: NetworkCacheManager, +) : SetupSwishPayinUseCase { + override suspend fun invoke(phoneNumber: String): Either = either { + apolloClient + .mutation(SetupSwishPayinMutation(PaymentMethodSetupSwishInput(phoneNumber))) + .safeExecuteAllowingPartialResponses() + .fold( + fa = { error -> + logcat { "SetupSwishPayinMutation error: $error" } + raise(ErrorMessage()) + }, + fb = { result -> + val output = result.paymentMethodSetupSwishPayin + when (output.status) { + PaymentMethodSetupStatus.ACTIVE -> { + logcat { + "SetupSwishPayinMutation ACTIVE, url present: ${output.url != null}" + } + networkCacheManager.clearCache() + SetupSwishResponse.Success(output.url, output.orderId) + } + + PaymentMethodSetupStatus.PENDING -> { + logcat { + "SetupSwishPayinMutation PENDING, url present: ${output.url != null}" + } + networkCacheManager.clearCache() + SetupSwishResponse.Pending(output.url, output.orderId) + } + + PaymentMethodSetupStatus.FAILED, PaymentMethodSetupStatus.UNKNOWN__ -> { + logcat { + "SetupSwishPayinMutation FAILED: ${output.error?.message}" + } + val userMessage = output.error?.message + SetupSwishResponse.Failure(ErrorMessage(userMessage)) + } + } + }, + fab = { errors, _ -> + logcat { "SetupSwishPayinMutation data with errors: $errors" } + raise(ErrorMessage()) + }, + ) + } +} + +internal sealed interface SetupSwishResponse { + data class Failure(val error: ErrorMessage) : SetupSwishResponse + + data class Success(val url: String?, val orderId: String?) : SetupSwishResponse + + data class Pending(val url: String?, val orderId: String?) : SetupSwishResponse +} + +/** + * A setup the member still has to approve in the Swish app: [successUrl] hands them over to it and + * [orderId] is what the approval is polled with. Null until the backend returns both, which it does + * for every setup that actually needs approving. + */ +internal data class SwishSetupOrder(val successUrl: String, val orderId: String) + +internal val SetupSwishResponse.order: SwishSetupOrder? + get() = when (this) { + is SetupSwishResponse.Success -> swishSetupOrder(url, orderId) + is SetupSwishResponse.Pending -> swishSetupOrder(url, orderId) + is SetupSwishResponse.Failure -> null + } + +private fun swishSetupOrder(url: String?, orderId: String?): SwishSetupOrder? { + if (url == null || orderId == null) return null + return SwishSetupOrder(url, orderId) +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDeepLinks.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDeepLinks.kt new file mode 100644 index 0000000000..c0ec6f1b3b --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDeepLinks.kt @@ -0,0 +1,19 @@ +package com.hedvig.android.feature.payin.account.navigation + +import androidx.navigation3.runtime.deeplink.DeepLinkMatcher +import com.hedvig.android.core.common.di.AppScope +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.DeepLinkMatcherProvider +import com.hedvig.android.navigation.compose.uriDeepLinkMatchers +import com.hedvig.android.navigation.core.HedvigDeepLinkContainer +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject + +@ContributesIntoSet(AppScope::class) +@Inject +internal class PayinAccountDeepLinkMatcherProvider( + private val container: HedvigDeepLinkContainer, +) : DeepLinkMatcherProvider { + override fun matchers(): List> = + uriDeepLinkMatchers(container.connectPayment, PayinAccountKey.serializer()) +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDestination.kt new file mode 100644 index 0000000000..f64787bbbf --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountDestination.kt @@ -0,0 +1,18 @@ +package com.hedvig.android.feature.payin.account.navigation + +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.Serializable + +/** + * Waiting on a Swish setup the member approves in the Swish app. Carries the phone number so a + * failed attempt can be retried with it, without sending them back to re-enter it. + */ +@Serializable +internal data class SwishPayinStatusKey( + val successUrl: String, + val orderId: String, + val phoneNumber: String, +) : HedvigNavKey + +@Serializable +internal data object SetupInvoicePayinKey : HedvigNavKey diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountEntries.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountEntries.kt new file mode 100644 index 0000000000..f39691ae30 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/navigation/PayinAccountEntries.kt @@ -0,0 +1,173 @@ +package com.hedvig.android.feature.payin.account.navigation + +import androidx.lifecycle.compose.dropUnlessResumed +import androidx.navigation3.runtime.EntryProviderScope +import com.hedvig.android.compose.ui.dropUnlessResumed +import com.hedvig.android.core.buildconstants.HedvigBuildConstants +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.design.system.hedvig.GlobalSnackBarState +import com.hedvig.android.feature.payin.account.data.SwishSetupOrder +import com.hedvig.android.feature.payin.account.data.id +import com.hedvig.android.feature.payin.account.ui.methoddetails.PayinMethodDetailsDestination +import com.hedvig.android.feature.payin.account.ui.methoddetails.PayinMethodDetailsViewModel +import com.hedvig.android.feature.payin.account.ui.methoddetails.PayinMethodDetailsViewModelFactory +import com.hedvig.android.feature.payin.account.ui.overview.PayinAccountOverviewDestination +import com.hedvig.android.feature.payin.account.ui.overview.PayinAccountOverviewUiState +import com.hedvig.android.feature.payin.account.ui.overview.PayinAccountOverviewViewModel +import com.hedvig.android.feature.payin.account.ui.primary.SelectPrimaryPayinMethodDestination +import com.hedvig.android.feature.payin.account.ui.primary.SelectPrimaryPayinMethodViewModel +import com.hedvig.android.feature.payin.account.ui.primary.SelectPrimaryPayinMethodViewModelFactory +import com.hedvig.android.feature.payin.account.ui.selectmethod.ConnectPayinMethodDestination +import com.hedvig.android.feature.payin.account.ui.selectmethod.ConnectPayinMethodViewModel +import com.hedvig.android.feature.payin.account.ui.selectmethod.ConnectPayinMethodViewModelFactory +import com.hedvig.android.feature.payin.account.ui.setupinvoice.SetupInvoicePayinDestination +import com.hedvig.android.feature.payin.account.ui.setupinvoice.SetupInvoicePayinViewModel +import com.hedvig.android.feature.payin.account.ui.setupswish.SetupSwishPayinDestination +import com.hedvig.android.feature.payin.account.ui.setupswish.SetupSwishPayinViewModel +import com.hedvig.android.feature.payin.account.ui.setupswish.SwishPayinStatusDestination +import com.hedvig.android.feature.payin.account.ui.setupswish.SwishPayinStatusViewModel +import com.hedvig.android.feature.payin.account.ui.setupswish.SwishPayinStatusViewModelFactory +import com.hedvig.android.navigation.common.HedvigNavKey +import com.hedvig.android.navigation.compose.Backstack +import com.hedvig.android.navigation.compose.add +import com.hedvig.android.navigation.compose.popUpTo +import com.hedvig.android.navigation.compose.removeAllOf +import dev.zacsweers.metrox.viewmodel.assistedMetroViewModel +import dev.zacsweers.metrox.viewmodel.metroViewModel + +fun EntryProviderScope.payinAccountEntries( + backstack: Backstack, + globalSnackBarState: GlobalSnackBarState, + hedvigBuildConstants: HedvigBuildConstants, + navigateToConnectPayment: () -> Unit, + openUrl: (String) -> Unit, +) { + entry { + val viewModel: PayinAccountOverviewViewModel = metroViewModel() + PayinAccountOverviewDestination( + viewModel = viewModel, + onConnectPayoutMethodClicked = dropUnlessResumed { + val content = viewModel.uiState.value as? PayinAccountOverviewUiState.Content + backstack.add( + SelectPayinMethodKey( + availableProviders = content?.availablePayinMethods?.map { it.rawValue } ?: emptyList(), + currentProviders = content?.currentMethods?.map { it.provider.rawValue } ?: emptyList(), + ), + ) + }, + onPayinMethodClicked = dropUnlessResumed { method: PayinAccount -> + backstack.add(PayinMethodDetailsKey(method.id)) + }, + onChoosePrimaryMethodClicked = dropUnlessResumed { + val content = viewModel.uiState.value as? PayinAccountOverviewUiState.Content + backstack.add(SelectPrimaryPayinMethodKey(currentMethods = content?.currentMethods ?: emptyList())) + }, + navigateUp = backstack::navigateUp, + ) + } + + entry { key -> + val viewModel: PayinMethodDetailsViewModel = + assistedMetroViewModel { + create(key.method) + } + PayinMethodDetailsDestination( + viewModel = viewModel, + navigateUp = backstack::navigateUp, + navigateBack = backstack::popBackstack, + onChangeMethod = dropUnlessResumed { method: PayinAccount -> + when (method) { + is PayinAccount.Trustly -> { + backstack.popUpTo(inclusive = true) + navigateToConnectPayment() + } + + is PayinAccount.SwishPayin -> { + backstack.add(SetupSwishPayinKey) + } + + is PayinAccount.Invoice -> { + backstack.add(SetupInvoicePayinKey) + } + } + }, + ) + } + + entry { key -> + val viewModel: SelectPrimaryPayinMethodViewModel = + assistedMetroViewModel { + create(key.currentMethods) + } + SelectPrimaryPayinMethodDestination( + viewModel = viewModel, + navigateUp = backstack::navigateUp, + navigateBack = backstack::popBackstack, + ) + } + + entry { key -> + val viewModel: ConnectPayinMethodViewModel = + assistedMetroViewModel { + create(key.availableProviders, key.currentProviders) + } + ConnectPayinMethodDestination( + viewModel = viewModel, + onTrustlySelected = dropUnlessResumed { + backstack.popUpTo(inclusive = true) + navigateToConnectPayment() + }, + onSwishSelected = dropUnlessResumed { backstack.add(SetupSwishPayinKey) }, + onInvoiceSelected = dropUnlessResumed { backstack.add(SetupInvoicePayinKey) }, + navigateUp = backstack::navigateUp, + ) + } + + // Leaves the whole Swish flow behind: both of its screens, plus the method picker when the member + // came through one, so a connected member never lands back inside the flow they just finished. + val finishSwishSetup: () -> Unit = { + backstack.popUpTo(inclusive = true) + backstack.removeAllOf() + } + + entry { + val viewModel: SetupSwishPayinViewModel = metroViewModel() + SetupSwishPayinDestination( + viewModel = viewModel, + globalSnackBarState = globalSnackBarState, + onSuccessfullyConnected = finishSwishSetup, + navigateToApproval = dropUnlessResumed { order: SwishSetupOrder, phoneNumber: String -> + backstack.add(SwishPayinStatusKey(order.successUrl, order.orderId, phoneNumber)) + }, + navigateUp = backstack::navigateUp, + ) + } + + entry { key -> + val viewModel: SwishPayinStatusViewModel = + assistedMetroViewModel { + create(key.successUrl, key.orderId, key.phoneNumber) + } + SwishPayinStatusDestination( + viewModel = viewModel, + // Staging orders can only be approved in the Swish sandbox app, never the real one. + allowSandboxSwishApp = !hedvigBuildConstants.isProduction, + navigateUp = backstack::navigateUp, + navigateBack = backstack::popBackstack, + finishSwishSetup = finishSwishSetup, + // Back to the picker, leaving the number behind with the attempt that failed. + changePaymentMethod = { backstack.popUpTo(inclusive = true) }, + openUrl = openUrl, + ) + } + + entry { + val viewModel: SetupInvoicePayinViewModel = metroViewModel() + SetupInvoicePayinDestination( + viewModel = viewModel, + globalSnackBarState = globalSnackBarState, + navigateUp = backstack::navigateUp, + ) + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodHandoverIllustration.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodHandoverIllustration.kt new file mode 100644 index 0000000000..82fc913acb --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodHandoverIllustration.kt @@ -0,0 +1,55 @@ +package com.hedvig.android.feature.payin.account.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HedvigTheme.colorScheme +import com.hedvig.android.design.system.hedvig.PaymentMethodHandoverIllustration +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.Surface +import octopus.type.MemberPaymentProvider + +@Composable +internal fun PayinMethodHandoverIllustration(provider: MemberPaymentProvider?, modifier: Modifier = Modifier) { + Column(modifier) { + Spacer(Modifier.height(48.dp)) + PaymentMethodHandoverIllustration( + mark = { PayinProviderMark(provider, Modifier.size(PaymentMethodMarkSize)) }, + ) + Spacer(Modifier.height(48.dp)) + } +} + +@Composable +@HedvigPreview +private fun PreviewPayinMethodHandoverIllustration( + @PreviewParameter(PayinPreviewProvider::class) provider: MemberPaymentProvider?, +) { + HedvigTheme { + Surface(color = colorScheme.backgroundPrimary) { + PayinMethodHandoverIllustration( + provider, + Modifier.padding(16.dp), + ) + } + } +} + +private class PayinPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + null, + MemberPaymentProvider.TRUSTLY, + MemberPaymentProvider.SWISH, + MemberPaymentProvider.INVOICE, + ), + ) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodRow.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodRow.kt new file mode 100644 index 0000000000..c0464772af --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/components/PayinMethodRow.kt @@ -0,0 +1,233 @@ +package com.hedvig.android.feature.payin.account.ui.components + +import androidx.compose.foundation.Image +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 +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HedvigTheme.colorScheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.PaymentMethodPillow +import com.hedvig.android.design.system.hedvig.PaymentMethodPillowMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodPlusMark +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Trustly +import com.hedvig.android.design.system.hedvig.icon.colored.Kivra +import com.hedvig.android.design.system.hedvig.icon.colored.Swish +import com.hedvig.android.feature.payin.account.data.toDeliveryString +import hedvig.resources.PAYMENTS_BANK_LABEL +import hedvig.resources.PAYMENT_PRIMARY_LABEL +import hedvig.resources.REFERRAL_PENDING_STATUS_LABEL +import hedvig.resources.Res +import hedvig.resources.swish +import octopus.type.MemberPaymentProvider +import org.jetbrains.compose.resources.stringResource + +/** + * The method's brand mark on the tile it is drawn on: white behind the full-colour marks, black + * behind Trustly's monochrome one, which picks up the tile's content colour. + */ +@Composable +internal fun PayinProviderPillow(provider: MemberPaymentProvider?, modifier: Modifier = Modifier) { + val onDarkTile = provider == MemberPaymentProvider.TRUSTLY + PaymentMethodPillow( + modifier = modifier, + containerColor = if (onDarkTile) HedvigTheme.colorScheme.fillBlack else HedvigTheme.colorScheme.fillWhite, + contentColor = if (onDarkTile) HedvigTheme.colorScheme.fillWhite else HedvigTheme.colorScheme.fillBlack, + mark = { PayinProviderMark(provider, Modifier.size(PaymentMethodPillowMarkSize)) }, + ) +} + +/** + * The method's brand mark. Trustly's is monochrome and picks up the surrounding content colour; the + * others are full-colour and ignore it. A provider with no mark of its own falls back to the plus, + * so one the backend adds later still renders. + */ +@Composable +internal fun PayinProviderMark(provider: MemberPaymentProvider?, modifier: Modifier = Modifier) { + when (provider) { + MemberPaymentProvider.TRUSTLY -> Icon(HedvigIcons.Trustly, EmptyContentDescription, modifier) + MemberPaymentProvider.SWISH -> Image(HedvigIcons.Swish, EmptyContentDescription, modifier) + MemberPaymentProvider.INVOICE -> Image(HedvigIcons.Kivra, EmptyContentDescription, modifier) + else -> PaymentMethodPlusMark(modifier) + } +} + +@Composable +internal fun PayinMethodMark(method: PayinAccount, modifier: Modifier = Modifier) { + PayinProviderMark(method.provider, modifier) +} + +@Composable +internal fun payinMethodTitle(method: PayinAccount): String = when (method) { + is PayinAccount.Trustly -> stringResource(Res.string.PAYMENTS_BANK_LABEL) + is PayinAccount.SwishPayin -> stringResource(Res.string.swish) + is PayinAccount.Invoice -> method.delivery.toDeliveryString().orEmpty() +} + +@Composable +internal fun payinMethodSubtitle(method: PayinAccount): String? { + val pendingLabel = stringResource(Res.string.REFERRAL_PENDING_STATUS_LABEL) + return when (method) { + is PayinAccount.Trustly -> { + method.maskedAccount() ?: pendingLabel.takeIf { method.isPending } + } + + is PayinAccount.SwishPayin -> { + val phoneNumber = method.phoneNumber + if (phoneNumber.isNullOrBlank()) pendingLabel.takeIf { method.isPending } else formatSwishPhoneNumber(phoneNumber) + } + + is PayinAccount.Invoice -> { + null + } + } +} + +/** + * The trailing edge of a method row. Sized so a row without one keeps the same height as its + * neighbours. + */ +@Composable +internal fun PayinMethodRow( + method: PayinAccount, + modifier: Modifier = Modifier, + endSlot: @Composable (() -> Unit)? = null, +) { + Row( + modifier = modifier + .heightIn(min = 64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PayinProviderPillow(method.provider) + Spacer(Modifier.width(2.dp)) + Column( + Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) { + HedvigText(text = payinMethodTitle(method)) + val subtitle = payinMethodSubtitle(method) + if (subtitle != null) { + HedvigText( + text = subtitle, + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } + endSlot?.invoke() + } +} + +/** The same mark, title and subtitle as [PayinMethodRow], for the screens that list methods as radio options. */ +@Composable +internal fun PayinAccount.toRadioOption(): RadioOption = RadioOption( + id = RadioOptionId(provider.rawValue), + text = payinMethodTitle(this), + label = payinMethodSubtitle(this), +) + +/** Static counterpart to the button the design draws it with: the primary method is not a choice made here. */ +@Composable +internal fun PrimaryMethodLabel(modifier: Modifier = Modifier) { + Surface( + shape = HedvigTheme.shapes.cornerXXLarge, + color = HedvigTheme.colorScheme.buttonSecondaryAltResting, + modifier = modifier, + ) { + HedvigText( + text = stringResource(Res.string.PAYMENT_PRIMARY_LABEL), + style = HedvigTheme.typography.label, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + ) + } +} + +private fun PayinAccount.Trustly.maskedAccount(): String? { + val bank = bankName ?: return null + val lastFour = accountNumber?.takeLast(4)?.takeIf { it.isNotBlank() } ?: return bank + return "$bank ···· $lastFour" +} + +internal fun formatSwishPhoneNumber(phoneNumber: String): String { + val digits = phoneNumber.take(15) + val sb = StringBuilder() + for (i in digits.indices) { + sb.append(digits[i]) + if (i in setOf(2, 5, 7)) { + sb.append("-") + } + } + return sb.toString() +} + +@Composable +@HedvigPreview +private fun PreviewPayinMethodRow( + @PreviewParameter(PayinAccountPreviewProvider::class) account: PayinAccount, +) { + HedvigTheme { + Surface(color = colorScheme.backgroundPrimary) { + HedvigCard( + shape = HedvigTheme.shapes.cornerLarge, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + PayinMethodRow( + account, + Modifier.fillMaxWidth(), + ) + } + } + } +} + +private class PayinAccountPreviewProvider : CollectionPreviewParameterProvider( + listOf( + PayinAccount.Trustly( + clearingNumber = "****", + accountNumber = "*45678", + bankName = "Swedbank", + isPending = false, + isDefault = true, + ), + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = false, + ), + PayinAccount.Invoice( + delivery = InvoiceDelivery.Kivra, + isPending = false, + isDefault = false, + email = "", + ), + ), +) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsDestination.kt new file mode 100644 index 0000000000..de445c930f --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsDestination.kt @@ -0,0 +1,371 @@ +package com.hedvig.android.feature.payin.account.ui.methoddetails + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.maskedAccountNumber +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigBottomSheet +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress +import com.hedvig.android.design.system.hedvig.HedvigRedTextButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HorizontalDivider +import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.IconButton +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.api.HedvigBottomSheetState +import com.hedvig.android.design.system.hedvig.datepicker.getLocale +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.InfoFilled +import com.hedvig.android.design.system.hedvig.rememberHedvigBottomSheetState +import com.hedvig.android.feature.payin.account.data.toDeliveryString +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodRow +import com.hedvig.android.feature.payin.account.ui.components.PrimaryMethodLabel +import hedvig.resources.GENERAL_REMOVE +import hedvig.resources.KIVRA_PAYMENT_INFO +import hedvig.resources.PAYMENTS_ACCOUNT +import hedvig.resources.PAYMENTS_AUTOGIRO_LABEL +import hedvig.resources.PAYMENTS_BANK_LABEL +import hedvig.resources.PAYMENTS_DUE_DESCRIPTION +import hedvig.resources.PAYMENTS_INVOICE +import hedvig.resources.PAYMENTS_PAYMENT_DUE +import hedvig.resources.PAYMENTS_PAYMENT_DUE_INFO +import hedvig.resources.PAYMENTS_PAYMENT_METHOD +import hedvig.resources.PAYMENTS_SWISH_NUMBER +import hedvig.resources.PAYMENT_SWISH_CHANGE_NUMBER +import hedvig.resources.PROFILE_PAYMENT_CHANGE_BANK_ACCOUNT +import hedvig.resources.REFERRALS_INFO_BUTTON_CONTENT_DESCRIPTION +import hedvig.resources.Res +import hedvig.resources.general_close_button +import hedvig.resources.something_went_wrong +import hedvig.resources.swish +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PayinMethodDetailsDestination( + viewModel: PayinMethodDetailsViewModel, + navigateUp: () -> Unit, + navigateBack: () -> Unit, + onChangeMethod: (PayinAccount) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val hasRemovedMethod = (uiState as? PayinMethodDetailsUiState.Content)?.hasRemovedMethod == true + LaunchedEffect(hasRemovedMethod) { + if (hasRemovedMethod) navigateBack() + } + when (val state = uiState) { + PayinMethodDetailsUiState.Loading -> { + HedvigScaffold( + topAppBarText = stringResource(Res.string.PAYMENTS_PAYMENT_METHOD), + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + HedvigFullScreenCenterAlignedProgress(Modifier.weight(1f)) + } + } + + PayinMethodDetailsUiState.Error -> { + HedvigScaffold( + topAppBarText = stringResource(Res.string.PAYMENTS_PAYMENT_METHOD), + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + HedvigErrorSection( + onButtonClick = { viewModel.emit(PayinMethodDetailsEvent.Retry) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .weight(1f), + ) + } + } + + is PayinMethodDetailsUiState.Content -> { + PayinMethodDetailsScreen( + method = state.method, + chargingDay = state.chargingDay, + isRemoving = state.isRemoving, + removeErrorMessage = state.removeError?.let { + it.message ?: stringResource(Res.string.something_went_wrong) + }, + navigateUp = navigateUp, + onChangeMethod = { onChangeMethod(state.method) }, + onConfirmRemoveMethod = { viewModel.emit(PayinMethodDetailsEvent.RemoveMethod) }, + ) + } + } +} + +@Composable +private fun PayinMethodDetailsScreen( + method: PayinAccount, + chargingDay: Int?, + isRemoving: Boolean, + removeErrorMessage: String?, + navigateUp: () -> Unit, + onChangeMethod: () -> Unit, + onConfirmRemoveMethod: () -> Unit, +) { + val explanationSheetState = rememberHedvigBottomSheetState() + ExplanationBottomSheet(explanationSheetState) + val removeSheetState = rememberHedvigBottomSheetState() + RemovePayinMethodBottomSheet( + sheetState = removeSheetState, + isRemoving = isRemoving, + errorMessage = removeErrorMessage, + onConfirmRemove = onConfirmRemoveMethod, + ) + HedvigScaffold( + topAppBarText = stringResource(Res.string.PAYMENTS_PAYMENT_METHOD), + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + HedvigCard( + shape = HedvigTheme.shapes.cornerLarge, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + PayinMethodRow( + method = method, + modifier = Modifier.fillMaxWidth(), + endSlot = { if (method.isDefault) PrimaryMethodLabel() }, + ) + } + Spacer(Modifier.height(8.dp)) + DetailRow( + label = stringResource(Res.string.PAYMENTS_PAYMENT_METHOD), + value = when (method) { + is PayinAccount.Trustly -> stringResource(Res.string.PAYMENTS_AUTOGIRO_LABEL) + is PayinAccount.SwishPayin -> stringResource(Res.string.swish) + is PayinAccount.Invoice -> stringResource(Res.string.PAYMENTS_INVOICE) + }, + ) + if (chargingDay != null) { + val formattedDay = chargingDay.format() + DetailRow( + label = stringResource(Res.string.PAYMENTS_PAYMENT_DUE), + value = stringResource(Res.string.PAYMENTS_DUE_DESCRIPTION, formattedDay), + endSlot = { + val explanation = method.explanation(formattedDay) + if (explanation != null) { + IconButton( + onClick = { explanationSheetState.show(explanation) }, + modifier = Modifier.size(24.dp), + ) { + Icon( + imageVector = HedvigIcons.InfoFilled, + contentDescription = stringResource(Res.string.REFERRALS_INFO_BUTTON_CONTENT_DESCRIPTION), + modifier = Modifier.size(24.dp), + tint = HedvigTheme.colorScheme.fillSecondaryTransparent, + ) + } + } + }, + ) + } + when (method) { + is PayinAccount.SwishPayin -> { + DetailRow( + label = stringResource(Res.string.PAYMENTS_SWISH_NUMBER), + value = method.phoneNumber.orEmpty(), + ) + } + + is PayinAccount.Trustly -> { + DetailRow( + label = stringResource(Res.string.PAYMENTS_ACCOUNT), + value = method.maskedAccountNumber().orEmpty(), + ) + DetailRow( + label = stringResource(Res.string.PAYMENTS_BANK_LABEL), + value = method.bankName.orEmpty(), + ) + } + + is PayinAccount.Invoice -> { + DetailRow( + label = stringResource(Res.string.PAYMENTS_ACCOUNT), + value = method.email ?: method.delivery.toDeliveryString().orEmpty(), + ) + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + val changeButtonText = when (method) { + is PayinAccount.SwishPayin -> stringResource(Res.string.PAYMENT_SWISH_CHANGE_NUMBER) + is PayinAccount.Trustly -> stringResource(Res.string.PROFILE_PAYMENT_CHANGE_BANK_ACCOUNT) + is PayinAccount.Invoice -> null + } + if (changeButtonText != null) { + HedvigButton( + text = changeButtonText, + onClick = onChangeMethod, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + } + HedvigRedTextButton( + text = stringResource(Res.string.GENERAL_REMOVE), + onClick = { removeSheetState.show(method) }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailRow(label: String, value: String, endSlot: @Composable (() -> Unit)? = null) { + HorizontalItemsWithMaximumSpaceTaken( + startSlot = { HedvigText(label) }, + endSlot = { + Row(horizontalArrangement = Arrangement.End, verticalAlignment = Alignment.CenterVertically) { + HedvigText( + text = value, + textAlign = TextAlign.End, + modifier = Modifier.weight(1f, fill = false), + color = HedvigTheme.colorScheme.textSecondary, + ) + if (endSlot != null) { + Spacer(Modifier.width(8.dp)) + endSlot() + } + } + }, + spaceBetween = 8.dp, + modifier = Modifier.padding(horizontal = 18.dp, vertical = 16.dp), + ) + HorizontalDivider(Modifier.padding(horizontal = 18.dp)) +} + +private sealed interface PaymentDueExplanation { + data object Kivra : PaymentDueExplanation + + data class DirectDebit(val dueDate: String) : PaymentDueExplanation +} + +private fun PayinAccount.explanation(formattedDay: String): PaymentDueExplanation? = when { + this is PayinAccount.Trustly -> PaymentDueExplanation.DirectDebit(formattedDay) + this is PayinAccount.Invoice && delivery == InvoiceDelivery.Kivra -> PaymentDueExplanation.Kivra + else -> null +} + +@Composable +private fun ExplanationBottomSheet(sheetState: HedvigBottomSheetState) { + HedvigBottomSheet(sheetState) { explanation -> + HedvigText( + text = when (explanation) { + PaymentDueExplanation.Kivra -> { + stringResource(Res.string.KIVRA_PAYMENT_INFO) + } + + is PaymentDueExplanation.DirectDebit -> { + stringResource(Res.string.PAYMENTS_PAYMENT_DUE_INFO, explanation.dueDate) + } + }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(32.dp)) + HedvigTextButton( + text = stringResource(Res.string.general_close_button), + onClick = { sheetState.dismiss() }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun Int.format(): String { + val day = this + val lastDigit = day % 10 + + val locale = getLocale() + + val suffix = when (locale.language) { + "en" -> when (lastDigit) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } + + "sv" -> when (day) { + 11, 12 -> ":e" + + else -> when (lastDigit) { + 1, 2 -> ":a" + else -> ":e" + } + } + + else -> when (lastDigit) { + 1 -> "st" + 2 -> "nd" + 3 -> "rd" + else -> "th" + } + } + + return "$day$suffix" +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewPayinMethodDetailsDestination( + @PreviewParameter(PayinMethodPreviewProvider::class) method: PayinAccount, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + PayinMethodDetailsScreen( + method = method, + chargingDay = 27, + isRemoving = false, + removeErrorMessage = null, + navigateUp = {}, + onChangeMethod = {}, + onConfirmRemoveMethod = {}, + ) + } + } +} + +private class PayinMethodPreviewProvider : CollectionPreviewParameterProvider( + listOf( + PayinAccount.SwishPayin("0709901232", isPending = false, isDefault = true), + PayinAccount.Trustly("8327", "91234124", "Swedbank", isPending = false, isDefault = false), + PayinAccount.Invoice(InvoiceDelivery.Kivra, null, isPending = false, isDefault = false), + ), +) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsViewModel.kt new file mode 100644 index 0000000000..f797002632 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/PayinMethodDetailsViewModel.kt @@ -0,0 +1,115 @@ +package com.hedvig.android.feature.payin.account.ui.methoddetails + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.feature.payin.account.data.GetPayinAccountUseCase +import com.hedvig.android.feature.payin.account.data.RemoveMethodUseCase +import com.hedvig.android.feature.payin.account.data.id +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject + +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class PayinMethodDetailsViewModel( + @Assisted methodId: PayinMethodId, + getPayinAccountUseCase: GetPayinAccountUseCase, + removeMethodUseCase: RemoveMethodUseCase, +) : MoleculeViewModel( + initialState = PayinMethodDetailsUiState.Loading, + presenter = PayinMethodDetailsPresenter(methodId, getPayinAccountUseCase, removeMethodUseCase), + ) + +internal sealed interface PayinMethodDetailsEvent { + data object Retry : PayinMethodDetailsEvent + + data object RemoveMethod : PayinMethodDetailsEvent +} + +internal sealed interface PayinMethodDetailsUiState { + data object Loading : PayinMethodDetailsUiState + + data object Error : PayinMethodDetailsUiState + + data class Content( + val method: PayinAccount, + val chargingDay: Int?, + val isRemoving: Boolean = false, + val removeError: ErrorMessage? = null, + val hasRemovedMethod: Boolean = false, + ) : PayinMethodDetailsUiState +} + +internal class PayinMethodDetailsPresenter( + private val methodId: PayinMethodId, + private val getPayinAccountUseCase: GetPayinAccountUseCase, + private val removeMethodUseCase: RemoveMethodUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PayinMethodDetailsUiState, + ): PayinMethodDetailsUiState { + var loadIteration by remember { mutableIntStateOf(0) } + var uiState by remember { mutableStateOf(lastState) } + var methodToRemove by remember { mutableStateOf(null) } + + LaunchedEffect(loadIteration) { + uiState = PayinMethodDetailsUiState.Loading + getPayinAccountUseCase.invoke().fold( + ifLeft = { uiState = PayinMethodDetailsUiState.Error }, + ifRight = { data -> + val method = data.currentMethods.firstOrNull { it.id == methodId } + uiState = if (method == null) { + PayinMethodDetailsUiState.Error + } else { + PayinMethodDetailsUiState.Content(method, data.chargingDay) + } + }, + ) + } + + LaunchedEffect(methodToRemove) { + val method = methodToRemove ?: return@LaunchedEffect + val content = uiState as? PayinMethodDetailsUiState.Content ?: return@LaunchedEffect + uiState = content.copy(isRemoving = true, removeError = null) + removeMethodUseCase.invoke(method.provider).fold( + ifLeft = { error -> + methodToRemove = null + uiState = content.copy(isRemoving = false, removeError = error) + }, + ifRight = { + uiState = content.copy(isRemoving = true, hasRemovedMethod = true) + }, + ) + } + + CollectEvents { event -> + when (event) { + PayinMethodDetailsEvent.Retry -> { + loadIteration++ + } + + PayinMethodDetailsEvent.RemoveMethod -> { + if (methodToRemove == null) { + methodToRemove = (uiState as? PayinMethodDetailsUiState.Content)?.method + } + } + } + } + + return uiState + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/RemovePayinMethodBottomSheet.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/RemovePayinMethodBottomSheet.kt new file mode 100644 index 0000000000..400471b80c --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/methoddetails/RemovePayinMethodBottomSheet.kt @@ -0,0 +1,151 @@ +package com.hedvig.android.feature.payin.account.ui.methoddetails + +import androidx.compose.foundation.layout.Box +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.design.system.hedvig.HedvigBottomSheet +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigNotificationCard +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HedvigTheme.colorScheme +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodTile +import com.hedvig.android.design.system.hedvig.PaymentMethodTileBadge +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.api.HedvigBottomSheetState +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Minus +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodMark +import com.hedvig.android.feature.payin.account.ui.components.PayinProviderPillow +import com.hedvig.android.feature.payin.account.ui.components.toRadioOption +import hedvig.resources.PAYMENT_REMOVE_SUBTITLE +import hedvig.resources.PAYMENT_REMOVE_TITLE +import hedvig.resources.REMOVE_CONFIRMATION_BUTTON +import hedvig.resources.Res +import hedvig.resources.general_cancel_button +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun RemovePayinMethodBottomSheet( + sheetState: HedvigBottomSheetState, + isRemoving: Boolean, + errorMessage: String?, + onConfirmRemove: () -> Unit, +) { + HedvigBottomSheet(sheetState) { method -> + RemovePayinMethodBottomSheetContent( + method = method, + isRemoving = isRemoving, + errorMessage = errorMessage, + onConfirmRemove = onConfirmRemove, + onDismiss = { sheetState.dismiss() }, + ) + } +} + +@Composable +private fun RemovePayinMethodBottomSheetContent( + method: PayinAccount, + isRemoving: Boolean, + errorMessage: String?, + onConfirmRemove: () -> Unit, + onDismiss: () -> Unit, +) { + HedvigText( + text = stringResource(Res.string.PAYMENT_REMOVE_TITLE), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + HedvigText( + text = stringResource(Res.string.PAYMENT_REMOVE_SUBTITLE), + textAlign = TextAlign.Center, + color = colorScheme.textSecondary, + modifier = Modifier.fillMaxWidth(), + ) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 64.dp), + ) { + PaymentMethodTile( + badge = { + PaymentMethodTileBadge( + icon = HedvigIcons.Minus, + containerColor = colorScheme.fillPrimary, + contentColor = colorScheme.fillNegative, + ) + }, + mark = { PayinMethodMark(method, Modifier.size(PaymentMethodMarkSize)) }, + ) + } + if (errorMessage != null) { + HedvigNotificationCard( + message = errorMessage, + priority = NotificationPriority.Error, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + } + val option = method.toRadioOption() + RadioGroup( + options = listOf(option), + selectedOption = option.id, + onRadioOptionSelected = {}, + optionIcon = { PayinProviderPillow(method.provider) }, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.REMOVE_CONFIRMATION_BUTTON), + onClick = onConfirmRemove, + enabled = !isRemoving, + isLoading = isRemoving, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(Res.string.general_cancel_button), + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewRemovePayinMethodBottomSheetContent() { + HedvigTheme { + Surface(color = colorScheme.backgroundPrimary) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + RemovePayinMethodBottomSheetContent( + method = PayinAccount.SwishPayin("0709901232", isPending = false, isDefault = false), + isRemoving = false, + errorMessage = null, + onConfirmRemove = {}, + onDismiss = {}, + ) + } + } + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewDestination.kt new file mode 100644 index 0000000000..51e7117231 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewDestination.kt @@ -0,0 +1,332 @@ +package com.hedvig.android.feature.payin.account.ui.overview + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.EmptyState +import com.hedvig.android.design.system.hedvig.EmptyStateDefaults +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigErrorSection +import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.ChevronRight +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodRow +import com.hedvig.android.feature.payin.account.ui.components.PrimaryMethodLabel +import hedvig.resources.PAYMENT_ADD_METHOD_BUTTON +import hedvig.resources.PAYMENT_CHOOSE_PRIMARY_BUTTON +import hedvig.resources.PAYMENT_METHODS_TITLE +import hedvig.resources.Res +import octopus.type.MemberPaymentProvider +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PayinAccountOverviewDestination( + viewModel: PayinAccountOverviewViewModel, + onConnectPayoutMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, + onPayinMethodClicked: (PayinAccount) -> Unit, + navigateUp: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + var hasResumedOnce by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + if (hasResumedOnce) { + viewModel.emit(PayinAccountOverviewEvent.Refresh) + } else { + hasResumedOnce = true + } + onPauseOrDispose {} + } + PayinAccountOverviewScreen( + uiState = uiState, + onConnectPayoutMethodClicked = onConnectPayoutMethodClicked, + onChoosePrimaryMethodClicked = onChoosePrimaryMethodClicked, + onPayinMethodClicked = onPayinMethodClicked, + onRetry = { viewModel.emit(PayinAccountOverviewEvent.Retry) }, + navigateUp = navigateUp, + ) +} + +@Composable +private fun PayinAccountOverviewScreen( + uiState: PayinAccountOverviewUiState, + onConnectPayoutMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, + onPayinMethodClicked: (PayinAccount) -> Unit, + onRetry: () -> Unit, + navigateUp: () -> Unit, +) { + HedvigScaffold( + topAppBarText = stringResource(Res.string.PAYMENT_METHODS_TITLE), + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + when (uiState) { + PayinAccountOverviewUiState.Loading -> { + HedvigFullScreenCenterAlignedProgressDebounced( + Modifier + .weight(1f) + .wrapContentHeight(), + ) + } + + PayinAccountOverviewUiState.Error -> { + HedvigErrorSection( + onButtonClick = onRetry, + modifier = Modifier + .fillMaxWidth() + .padding(16.dp) + .weight(1f) + .wrapContentHeight(), + ) + } + + is PayinAccountOverviewUiState.Content -> { + PayoutAccountContent( + currentMethods = uiState.currentMethods, + availablePayinMethods = uiState.availablePayinMethods, + onConnectPayinMethodClicked = onConnectPayoutMethodClicked, + onChoosePrimaryMethodClicked = onChoosePrimaryMethodClicked, + onPayinMethodClicked = onPayinMethodClicked, + modifier = Modifier.weight(1f), + ) + } + } + } +} + +@Composable +private fun PayoutAccountContent( + currentMethods: List, + availablePayinMethods: List, + onConnectPayinMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, + onPayinMethodClicked: (PayinAccount) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier + .verticalScroll(rememberScrollState()), + ) { + Spacer(Modifier.height(8.dp)) + if (currentMethods.isEmpty()) { + if (availablePayinMethods.isNotEmpty()) { + Spacer(Modifier.weight(1f)) + EmptyState( + // TODO: Add "You haven't added a billing method yet. Add one to pay for your insurance." / + // "Du har inte lagt till någon betalningsmetod än. Lägg till en för att betala för din försäkring." + // to Lokalise + text = "You haven’t added a billing method yet. Add one to pay for your insurance.", + description = null, + iconStyle = EmptyStateDefaults.EmptyStateIconStyle.INFO, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } else { + Column( + Modifier.padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + for (method in currentMethods) { + CurrentPayinMethodRow(method = method, onClick = { onPayinMethodClicked(method) }) + } + } + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + if (availablePayinMethods.isNotEmpty()) { + HedvigButton( + text = stringResource(Res.string.PAYMENT_ADD_METHOD_BUTTON), + onClick = onConnectPayinMethodClicked, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + if (currentMethods + .filter { !it.isPending } + .size > 1 + ) { + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(Res.string.PAYMENT_CHOOSE_PRIMARY_BUTTON), + onClick = onChoosePrimaryMethodClicked, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun CurrentPayinMethodRow(method: PayinAccount, onClick: () -> Unit, modifier: Modifier = Modifier) { + HedvigCard( + onClick = if (method.isPending) null else onClick, + shape = HedvigTheme.shapes.cornerLarge, + modifier = modifier.fillMaxWidth(), + ) { + PayinMethodRow( + method = method, + modifier = Modifier.fillMaxWidth(), + endSlot = { + if (method.isDefault) { + PrimaryMethodLabel() + } + if (!method.isPending) { + Icon( + imageVector = HedvigIcons.ChevronRight, + contentDescription = EmptyContentDescription, + modifier = Modifier.size(24.dp), + ) + } + }, + ) + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewPayinAccountOverviewScreen( + @PreviewParameter(PayinAccountOverviewUiStateProvider::class) uiState: PayinAccountOverviewUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + PayinAccountOverviewScreen( + uiState = uiState, + onConnectPayoutMethodClicked = {}, + onChoosePrimaryMethodClicked = {}, + onPayinMethodClicked = {}, + onRetry = {}, + navigateUp = {}, + ) + } + } +} + +private class PayinAccountOverviewUiStateProvider : CollectionPreviewParameterProvider( + listOf( + PayinAccountOverviewUiState.Loading, + PayinAccountOverviewUiState.Error, + PayinAccountOverviewUiState.Content( + currentMethods = emptyList(), + availablePayinMethods = listOf(MemberPaymentProvider.SWISH, MemberPaymentProvider.TRUSTLY), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf( + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = true, + ), + ), + availablePayinMethods = listOf(MemberPaymentProvider.SWISH), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf( + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = true, + ), + ), + availablePayinMethods = listOf(MemberPaymentProvider.SWISH, MemberPaymentProvider.TRUSTLY), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf(PayinAccount.SwishPayin(phoneNumber = null, isPending = true, isDefault = true)), + availablePayinMethods = listOf(MemberPaymentProvider.SWISH), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf( + PayinAccount.Trustly( + clearingNumber = "****", + accountNumber = "*45678", + bankName = "Swedbank", + isPending = true, + isDefault = false, + ), + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = true, + ), + ), + availablePayinMethods = listOf(MemberPaymentProvider.SWISH), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf( + PayinAccount.Trustly( + clearingNumber = "****", + accountNumber = "*45678", + bankName = "Swedbank", + isPending = false, + isDefault = true, + ), + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = false, + ), + PayinAccount.Invoice( + delivery = InvoiceDelivery.Kivra, + isPending = false, + isDefault = false, + email = "", + ), + ), + availablePayinMethods = listOf(MemberPaymentProvider.TRUSTLY), + ), + PayinAccountOverviewUiState.Content( + currentMethods = listOf( + PayinAccount.Trustly( + clearingNumber = "****", + accountNumber = "*45678", + bankName = "Swedbank", + isPending = false, + isDefault = true, + ), + PayinAccount.SwishPayin( + phoneNumber = "0701234567", + isPending = false, + isDefault = false, + ), + ), + availablePayinMethods = listOf(MemberPaymentProvider.TRUSTLY), + ), + ), +) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewViewModel.kt new file mode 100644 index 0000000000..1762a194c6 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/overview/PayinAccountOverviewViewModel.kt @@ -0,0 +1,92 @@ +package com.hedvig.android.feature.payin.account.ui.overview + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.feature.payin.account.data.GetPayinAccountUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject +import octopus.type.MemberPaymentProvider + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class PayinAccountOverviewViewModel( + getPayinAccountUseCase: GetPayinAccountUseCase, +) : MoleculeViewModel( + PayinAccountOverviewUiState.Loading, + PayinAccountOverviewPresenter(getPayinAccountUseCase), + ) + +internal sealed interface PayinAccountOverviewEvent { + data object Retry : PayinAccountOverviewEvent + + data object Refresh : PayinAccountOverviewEvent +} + +internal sealed interface PayinAccountOverviewUiState { + data object Loading : PayinAccountOverviewUiState + + data object Error : PayinAccountOverviewUiState + + data class Content( + val currentMethods: List, + val availablePayinMethods: List, + ) : PayinAccountOverviewUiState +} + +internal class PayinAccountOverviewPresenter( + private val getPayinAccountUseCase: GetPayinAccountUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: PayinAccountOverviewUiState, + ): PayinAccountOverviewUiState { + var loadIteration by remember { mutableIntStateOf(0) } + var refreshIteration by remember { mutableIntStateOf(0) } + var uiState by remember { mutableStateOf(lastState) } + + LaunchedEffect(loadIteration) { + uiState = PayinAccountOverviewUiState.Loading + getPayinAccountUseCase.invoke().fold( + ifLeft = { uiState = PayinAccountOverviewUiState.Error }, + ifRight = { data -> + uiState = PayinAccountOverviewUiState.Content( + currentMethods = data.currentMethods, + availablePayinMethods = data.availablePayinMethods, + ) + }, + ) + } + + // Picks up methods connected or removed further down the flow. It refreshes in place and keeps + // what is on screen if the refetch fails, so returning here never flashes to loading or to an + // error over a list that is still perfectly usable. + LaunchedEffect(refreshIteration) { + if (refreshIteration == 0) return@LaunchedEffect + getPayinAccountUseCase.invoke().onRight { data -> + uiState = PayinAccountOverviewUiState.Content( + currentMethods = data.currentMethods, + availablePayinMethods = data.availablePayinMethods, + ) + } + } + + CollectEvents { event -> + when (event) { + PayinAccountOverviewEvent.Retry -> loadIteration++ + PayinAccountOverviewEvent.Refresh -> refreshIteration++ + } + } + + return uiState + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodDestination.kt new file mode 100644 index 0000000000..081c44c53f --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodDestination.kt @@ -0,0 +1,334 @@ +package com.hedvig.android.feature.payin.account.ui.primary + +import androidx.compose.foundation.Image +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 +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.PayinAccount.Invoice +import com.hedvig.android.data.paying.member.PayinAccount.SwishPayin +import com.hedvig.android.data.paying.member.PayinAccount.Trustly +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.design.system.hedvig.HedvigBottomSheet +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigNotificationCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.HedvigTheme.colorScheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Error +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Info +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.ThreeDotsLoading +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.design.system.hedvig.api.HedvigBottomSheetState +import com.hedvig.android.design.system.hedvig.hedvigDropShadow +import com.hedvig.android.design.system.hedvig.icon.HelipadFilled +import com.hedvig.android.design.system.hedvig.icon.HelipadOutline +import com.hedvig.android.design.system.hedvig.icon.Plus +import com.hedvig.android.design.system.hedvig.rememberHedvigBottomSheetState +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodHandoverIllustration +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodRow +import com.hedvig.android.feature.payin.account.ui.components.PayinProviderPillow +import com.hedvig.android.feature.payin.account.ui.components.PrimaryMethodLabel +import com.hedvig.android.feature.payin.account.ui.components.payinMethodTitle +import com.hedvig.android.feature.payin.account.ui.components.toRadioOption +import com.hedvig.android.feature.payin.account.ui.primary.SelectPrimaryPayinMethodEvent.ConfirmSelectedMethod +import com.hedvig.android.feature.payin.account.ui.primary.SelectPrimaryPayinMethodEvent.SelectMethod +import hedvig.resources.PAYMENT_CONFIRM_PRIMARY_WARNING +import hedvig.resources.PAYMENT_CONNECT_SUBTITLE +import hedvig.resources.PAYMENT_CONNECT_TITLE +import hedvig.resources.PAYMENT_PRIMARY_CONFIRM_TITLE +import hedvig.resources.PAYMENT_PRIMARY_SUBTITLE +import hedvig.resources.PAYMENT_PRIMARY_TITLE +import hedvig.resources.Res +import hedvig.resources.Res.string +import hedvig.resources.general_cancel_button +import hedvig.resources.general_continue_button +import hedvig.resources.pillow_new_680 +import octopus.type.MemberPaymentProvider +import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SelectPrimaryPayinMethodDestination( + viewModel: SelectPrimaryPayinMethodViewModel, + navigateUp: () -> Unit, + navigateBack: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + LaunchedEffect(uiState.hasSetPrimaryMethod) { + if (uiState.hasSetPrimaryMethod) { + navigateBack() + } + } + SelectPrimaryPayinMethodScreen( + uiState = uiState, + onMethodSelected = { viewModel.emit(SelectMethod(it)) }, + onConfirm = { viewModel.emit(ConfirmSelectedMethod) }, + navigateUp = navigateUp, + navigateBack = navigateBack, + ) +} + +@Composable +private fun SelectPrimaryPayinMethodScreen( + uiState: SelectPrimaryPayinMethodUiState, + onMethodSelected: (PayinAccount) -> Unit, + onConfirm: () -> Unit, + navigateUp: () -> Unit, + navigateBack: () -> Unit, +) { + val confirmationSheetState = rememberHedvigBottomSheetState() + HedvigScaffold( + topAppBarText = null, + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + Spacer(Modifier.height(8.dp)) + FlowHeading( + title = stringResource(Res.string.PAYMENT_PRIMARY_TITLE), + description = stringResource(Res.string.PAYMENT_PRIMARY_SUBTITLE), + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PayinMethodHandoverIllustration( + provider = uiState.selectedMethod?.provider, + modifier = Modifier + .align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) + val currentDefault = uiState.methods.firstOrNull { it.isDefault } + if (currentDefault != null) { + HedvigCard( + shape = HedvigTheme.shapes.cornerLarge, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + PayinMethodRow( + method = currentDefault, + modifier = Modifier.fillMaxWidth(), + endSlot = { PrimaryMethodLabel() }, + ) + } + Spacer(Modifier.height(4.dp)) + } + RadioGroup( + options = uiState.methods + .filter { !it.isPending } + .filter { !it.isDefault } + .map { it.toRadioOption() }, + selectedOption = uiState.selectedMethod?.let { RadioOptionId(it.provider.rawValue) }, + onRadioOptionSelected = { id -> + val method = uiState.methods.firstOrNull { it.provider.rawValue == id.id } + if (method != null) onMethodSelected(method) + }, + optionIcon = { PayinProviderPillow(MemberPaymentProvider.safeValueOf(it.id)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + if (uiState.errorMessage != null) { + Spacer(Modifier.height(8.dp)) + HedvigNotificationCard( + message = uiState.errorMessage, + priority = Error, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(string.general_continue_button), + onClick = { + val method = uiState.selectedMethod + if (method != null) confirmationSheetState.show(method) + }, + enabled = uiState.selectedMethod != null && + !uiState.selectedMethod.isDefault, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(string.general_cancel_button), + onClick = navigateBack, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } + ConfirmPrimaryPayinMethodBottomSheet( + sheetState = confirmationSheetState, + isConfirming = uiState.isConfirming, + onConfirm = onConfirm, + ) +} + +@Composable +private fun ConfirmPrimaryPayinMethodBottomSheet( + sheetState: HedvigBottomSheetState, + isConfirming: Boolean, + onConfirm: () -> Unit, +) { + HedvigBottomSheet(sheetState) { method -> + ConfirmPrimaryPayinMethodBottomSheetContent( + method = method, + isConfirming = isConfirming, + onConfirm = onConfirm, + onDismiss = { sheetState.dismiss() }, + ) + } +} + +@Composable +private fun ConfirmPrimaryPayinMethodBottomSheetContent( + method: PayinAccount, + isConfirming: Boolean, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + HedvigText( + text = stringResource(string.PAYMENT_PRIMARY_CONFIRM_TITLE), + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(24.dp)) + if (method.provider != MemberPaymentProvider.INVOICE) { + HedvigNotificationCard( + message = stringResource(Res.string.PAYMENT_CONFIRM_PRIMARY_WARNING, payinMethodTitle(method)), + priority = Info, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) + } + Surface( + shape = HedvigTheme.shapes.cornerLarge, + color = colorScheme.surfacePrimary, + modifier = Modifier.fillMaxWidth(), + ) { + PayinMethodRow( + method = method, + modifier = Modifier.fillMaxWidth(), + endSlot = { PrimaryMethodLabel() }, + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(string.general_continue_button), + onClick = onConfirm, + enabled = !isConfirming, + isLoading = isConfirming, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(string.general_cancel_button), + onClick = onDismiss, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(16.dp)) +} + +@Composable +@HedvigShortMultiScreenPreview +private fun ConfirmPrimaryPayinMethodBottomSheetPreview() { + HedvigTheme { + Surface(color = colorScheme.backgroundPrimary) { + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + ) { + ConfirmPrimaryPayinMethodBottomSheetContent( + SwishPayin( + "0709901232", + isPending = false, + isDefault = false, + ), + isConfirming = false, + onConfirm = {}, + onDismiss = {}, + ) + } + } + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewSelectPrimaryPayinMethodScreen( + @PreviewParameter(SelectedMethodIndexProvider::class) selectedMethod: Int?, +) { + HedvigTheme { + Surface(color = colorScheme.backgroundPrimary) { + val methods = listOf( + Trustly( + "8327", + "91234124", + "Swedbank", + isPending = false, + isDefault = false, + ), + SwishPayin( + "0709901232", + isPending = false, + isDefault = true, + ), + Invoice( + delivery = InvoiceDelivery.Kivra, + email = null, + isPending = false, + isDefault = false, + ), + ) + SelectPrimaryPayinMethodScreen( + uiState = SelectPrimaryPayinMethodUiState( + methods = methods, + selectedMethod = selectedMethod?.let { methods[it] }, + ), + onMethodSelected = {}, + onConfirm = {}, + navigateUp = {}, + navigateBack = {}, + ) + } + } +} + +private class SelectedMethodIndexProvider : + CollectionPreviewParameterProvider( + listOf( + null, + 0, + 1, + 2, + ), + ) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodViewModel.kt new file mode 100644 index 0000000000..c506438dd8 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/primary/SelectPrimaryPayinMethodViewModel.kt @@ -0,0 +1,99 @@ +package com.hedvig.android.feature.payin.account.ui.primary + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider +import com.hedvig.android.feature.payin.account.data.SetAsDefaultUseCase +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject + +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class SelectPrimaryPayinMethodViewModel( + @Assisted currentMethods: List, + setAsDefaultUseCase: SetAsDefaultUseCase, +) : MoleculeViewModel( + initialState = SelectPrimaryPayinMethodUiState( + methods = currentMethods, + selectedMethod = null, + ), + presenter = SelectPrimaryPayinMethodPresenter(setAsDefaultUseCase), + ) + +internal sealed interface SelectPrimaryPayinMethodEvent { + data class SelectMethod(val method: PayinAccount) : SelectPrimaryPayinMethodEvent + + data object ConfirmSelectedMethod : SelectPrimaryPayinMethodEvent +} + +internal data class SelectPrimaryPayinMethodUiState( + val methods: List, + val selectedMethod: PayinAccount?, + val isConfirming: Boolean = false, + val errorMessage: String? = null, + val hasSetPrimaryMethod: Boolean = false, +) + +internal class SelectPrimaryPayinMethodPresenter( + private val setAsDefaultUseCase: SetAsDefaultUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: SelectPrimaryPayinMethodUiState, + ): SelectPrimaryPayinMethodUiState { + var selectedMethod by remember { mutableStateOf(lastState.selectedMethod) } + var isConfirming by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var hasSetPrimaryMethod by remember { mutableStateOf(false) } + var methodToConfirm by remember { mutableStateOf(null) } + + LaunchedEffect(methodToConfirm) { + val method = methodToConfirm ?: return@LaunchedEffect + isConfirming = true + errorMessage = null + setAsDefaultUseCase.invoke(method.provider).fold( + ifLeft = { + isConfirming = false + methodToConfirm = null + errorMessage = it.message + }, + ifRight = { + isConfirming = false + methodToConfirm = null + hasSetPrimaryMethod = true + }, + ) + } + + CollectEvents { event -> + when (event) { + is SelectPrimaryPayinMethodEvent.SelectMethod -> { + selectedMethod = event.method + } + + SelectPrimaryPayinMethodEvent.ConfirmSelectedMethod -> { + if (!isConfirming) { + methodToConfirm = selectedMethod + } + } + } + } + + return lastState.copy( + selectedMethod = selectedMethod, + isConfirming = isConfirming, + errorMessage = errorMessage, + hasSetPrimaryMethod = hasSetPrimaryMethod, + ) + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodDestination.kt new file mode 100644 index 0000000000..cc340d8af1 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodDestination.kt @@ -0,0 +1,196 @@ +package com.hedvig.android.feature.payin.account.ui.selectmethod + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.feature.payin.account.ui.components.PayinMethodHandoverIllustration +import com.hedvig.android.feature.payin.account.ui.components.PayinProviderPillow +import hedvig.resources.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL +import hedvig.resources.PAYMENTS_INVOICE +import hedvig.resources.PAYMENT_CONNECT_SUBTITLE +import hedvig.resources.PAYMENT_CONNECT_TITLE +import hedvig.resources.PAYMENT_OPTION_CONNECTED_LABEL +import hedvig.resources.PAYMENT_OPTION_INVOICE_SUBTITLE +import hedvig.resources.PAYMENT_OPTION_SWISH_SUBTITLE +import hedvig.resources.PAYMENT_OPTION_TRUSTLY_SUBTITLE +import hedvig.resources.Res +import hedvig.resources.general_cancel_button +import hedvig.resources.swish +import octopus.type.MemberPaymentProvider +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun ConnectPayinMethodDestination( + viewModel: ConnectPayinMethodViewModel, + onTrustlySelected: () -> Unit, + onSwishSelected: () -> Unit, + onInvoiceSelected: () -> Unit, + navigateUp: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + ConnectPayinMethodScreen( + uiState = uiState, + onProviderSelected = { viewModel.emit(SelectPayinMethodEvent.SelectProvider(it)) }, + onSubmitSelected = { + when (uiState.selectedProvider) { + MemberPaymentProvider.TRUSTLY -> { + onTrustlySelected() + } + + MemberPaymentProvider.SWISH -> { + onSwishSelected() + } + + MemberPaymentProvider.INVOICE -> { + onInvoiceSelected() + } + + else -> {} + } + }, + navigateUp = navigateUp, + ) +} + +@Composable +private fun ConnectPayinMethodScreen( + uiState: ConnectPayinMethodUiState, + onProviderSelected: (MemberPaymentProvider) -> Unit, + onSubmitSelected: () -> Unit, + navigateUp: () -> Unit, +) { + HedvigScaffold( + topAppBarText = null, + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + Spacer(Modifier.height(8.dp)) + FlowHeading( + title = stringResource(Res.string.PAYMENT_CONNECT_TITLE), + description = stringResource(Res.string.PAYMENT_CONNECT_SUBTITLE), + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PayinMethodHandoverIllustration( + uiState.selectedProvider, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) + RadioGroup( + options = uiState.availableProviders.mapNotNull { + it.toRadioOption( + uiState.currentProviders, + ) + }, + selectedOption = uiState.selectedProvider?.let { RadioOptionId(it.rawValue) }, + onRadioOptionSelected = { onProviderSelected(MemberPaymentProvider.safeValueOf(it.id)) }, + optionIcon = { PayinProviderPillow(MemberPaymentProvider.safeValueOf(it.id)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + onClick = onSubmitSelected, + enabled = uiState.selectedProvider != null, + text = stringResource(Res.string.PAYMENT_CONNECT_TITLE), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + onClick = navigateUp, + enabled = true, + text = stringResource(Res.string.general_cancel_button), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} + +/** + * Null for providers this screen has no option for, so a value the backend adds later is left out + * of the group. + */ +@Composable +private fun MemberPaymentProvider.toRadioOption(currentProviders: List): RadioOption? { + val id = RadioOptionId(rawValue) + return when (this) { + MemberPaymentProvider.TRUSTLY -> RadioOption( + id = id, + text = stringResource(Res.string.ONBOARDING_CONNECT_PAYMENT_BANK_LABEL), + label = if (currentProviders.contains(this)) { + stringResource(Res.string.PAYMENT_OPTION_CONNECTED_LABEL) + } else { + stringResource(Res.string.PAYMENT_OPTION_TRUSTLY_SUBTITLE) + }, + ) + + MemberPaymentProvider.SWISH -> RadioOption( + id = id, + text = stringResource(Res.string.swish), + label = if (currentProviders.contains(this)) { + stringResource(Res.string.PAYMENT_OPTION_CONNECTED_LABEL) + } else { + stringResource(Res.string.PAYMENT_OPTION_SWISH_SUBTITLE) + }, + ) + + MemberPaymentProvider.INVOICE -> RadioOption( + id = id, + text = stringResource(Res.string.PAYMENTS_INVOICE), + label = if (currentProviders.contains(this)) { + stringResource(Res.string.PAYMENT_OPTION_CONNECTED_LABEL) + } else { + stringResource(Res.string.PAYMENT_OPTION_INVOICE_SUBTITLE) + }, + ) + + else -> null + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewConnectPayinMethodScreen() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + ConnectPayinMethodScreen( + uiState = ConnectPayinMethodUiState( + availableProviders = listOf( + MemberPaymentProvider.SWISH, + MemberPaymentProvider.INVOICE, + MemberPaymentProvider.TRUSTLY, + ), + selectedProvider = MemberPaymentProvider.TRUSTLY, + currentProviders = listOf( + MemberPaymentProvider.SWISH, + ), + ), + onProviderSelected = {}, + onSubmitSelected = {}, + navigateUp = {}, + ) + } + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodViewModel.kt new file mode 100644 index 0000000000..d621c0845a --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/selectmethod/ConnectPayinMethodViewModel.kt @@ -0,0 +1,58 @@ +package com.hedvig.android.feature.payin.account.ui.selectmethod + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.feature.payin.account.ui.selectmethod.SelectPayinMethodEvent.SelectProvider +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import octopus.type.MemberPaymentProvider + +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class ConnectPayinMethodViewModel( + @Assisted availableProviders: List, + @Assisted currentProviders: List, +) : MoleculeViewModel( + initialState = ConnectPayinMethodUiState( + availableProviders = availableProviders.map(MemberPaymentProvider::safeValueOf), + selectedProvider = null, + currentProviders = currentProviders.map(MemberPaymentProvider::safeValueOf), + ), + presenter = SelectPayinMethodPresenter(), + ) + +internal sealed interface SelectPayinMethodEvent { + data class SelectProvider(val provider: MemberPaymentProvider) : SelectPayinMethodEvent +} + +internal data class ConnectPayinMethodUiState( + val currentProviders: List, + val availableProviders: List, + val selectedProvider: MemberPaymentProvider?, +) + +internal class SelectPayinMethodPresenter : + MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: ConnectPayinMethodUiState, + ): ConnectPayinMethodUiState { + var selectedProvider by remember { mutableStateOf(lastState.selectedProvider) } + + CollectEvents { event -> + when (event) { + is SelectProvider -> selectedProvider = event.provider + } + } + + return lastState.copy(selectedProvider = selectedProvider) + } +} diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinDestination.kt similarity index 59% rename from app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt rename to app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinDestination.kt index 6443ba3b0b..caeae878d6 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutDestination.kt +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinDestination.kt @@ -1,8 +1,10 @@ -package com.hedvig.android.feature.payoutaccount.ui.setupinvoice +package com.hedvig.android.feature.payin.account.ui.setupinvoice import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -11,38 +13,45 @@ import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigNotificationCard +import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.Surface import hedvig.resources.CONTACT_INFO_CHANGES_SAVED import hedvig.resources.PAYMENTS_INVOICE import hedvig.resources.Res import org.jetbrains.compose.resources.stringResource @Composable -internal fun SetupInvoicePayoutDestination( - viewModel: SetupInvoicePayoutViewModel, +internal fun SetupInvoicePayinDestination( + viewModel: SetupInvoicePayinViewModel, globalSnackBarState: GlobalSnackBarState, navigateUp: () -> Unit, ) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() - SetupInvoicePayoutScreen( + SetupInvoicePayinScreen( uiState = uiState, globalSnackBarState = globalSnackBarState, - onConnect = { viewModel.emit(SetupInvoicePayoutEvent.Connect) }, - showedSnackBar = { viewModel.emit(SetupInvoicePayoutEvent.ShowedSnackBar) }, + onConnect = { viewModel.emit(SetupInvoicePayinEvent.Connect) }, + showedSnackBar = { viewModel.emit(SetupInvoicePayinEvent.ShowedSnackBar) }, navigateUp = navigateUp, ) } @Composable -private fun SetupInvoicePayoutScreen( - uiState: SetupInvoicePayoutUiState, +private fun SetupInvoicePayinScreen( + uiState: SetupInvoicePayinUiState, globalSnackBarState: GlobalSnackBarState, onConnect: () -> Unit, showedSnackBar: () -> Unit, @@ -60,7 +69,21 @@ private fun SetupInvoicePayoutScreen( navigateUp = navigateUp, modifier = Modifier.fillMaxSize(), ) { - Spacer(Modifier.weight(1f)) + // todo: some text here?? + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + HedvigText( + "You can choose invoice as your billing method. " + + "You will then get a monthly invoice via Kivra or email if you don't have Kivra account.", + textAlign = TextAlign.Center, + ) + } AnimatedVisibility( visible = uiState.errorMessage != null, enter = expandVertically(), @@ -77,7 +100,7 @@ private fun SetupInvoicePayoutScreen( } Spacer(Modifier.height(16.dp)) HedvigButton( - text = "Connect", + text = "Set invoice as billing method", // todo onClick = onConnect, enabled = !uiState.isLoading, isLoading = uiState.isLoading, @@ -88,3 +111,23 @@ private fun SetupInvoicePayoutScreen( Spacer(Modifier.height(16.dp)) } } + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewPayoutAccountOverviewScreen() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + SetupInvoicePayinScreen( + uiState = SetupInvoicePayinUiState( + false, + null, + showSuccessSnackBar = false, + ), + globalSnackBarState = GlobalSnackBarState(), + {}, + {}, + {}, + ) + } + } +} diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinViewModel.kt similarity index 61% rename from app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt rename to app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinViewModel.kt index bd3830c236..17d91cbd26 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupinvoice/SetupInvoicePayoutViewModel.kt +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupinvoice/SetupInvoicePayinViewModel.kt @@ -1,4 +1,4 @@ -package com.hedvig.android.feature.payoutaccount.ui.setupinvoice +package com.hedvig.android.feature.payin.account.ui.setupinvoice import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -9,8 +9,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.feature.payoutaccount.data.SetupInvoicePayoutUseCase -import com.hedvig.android.feature.payoutaccount.navigation.SelectPayoutMethodKey +import com.hedvig.android.feature.payin.account.data.SetupInvoicePayinUseCase +import com.hedvig.android.feature.payin.account.navigation.SelectPayinMethodKey import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope import com.hedvig.android.molecule.public.MoleculeViewModel @@ -20,34 +20,34 @@ import dev.zacsweers.metro.Inject @Inject @HedvigViewModel(ActivityRetainedScope::class) -internal class SetupInvoicePayoutViewModel( - setupInvoicePayoutUseCase: SetupInvoicePayoutUseCase, +internal class SetupInvoicePayinViewModel( + setupInvoicePayinUseCase: SetupInvoicePayinUseCase, backstack: Backstack, -) : MoleculeViewModel( - SetupInvoicePayoutUiState(false, null, false), - SetupInvoicePayoutPresenter(setupInvoicePayoutUseCase, backstack), +) : MoleculeViewModel( + SetupInvoicePayinUiState(false, null, false), + SetupInvoicePayinPresenter(setupInvoicePayinUseCase, backstack), ) -internal sealed interface SetupInvoicePayoutEvent { - data object Connect : SetupInvoicePayoutEvent +internal sealed interface SetupInvoicePayinEvent { + data object Connect : SetupInvoicePayinEvent - data object ShowedSnackBar : SetupInvoicePayoutEvent + data object ShowedSnackBar : SetupInvoicePayinEvent } -internal data class SetupInvoicePayoutUiState( +internal data class SetupInvoicePayinUiState( val isLoading: Boolean, val errorMessage: String?, val showSuccessSnackBar: Boolean, ) -internal class SetupInvoicePayoutPresenter( - private val setupInvoicePayoutUseCase: SetupInvoicePayoutUseCase, +internal class SetupInvoicePayinPresenter( + private val setupInvoicePayinUseCase: SetupInvoicePayinUseCase, private val backstack: Backstack, -) : MoleculePresenter { +) : MoleculePresenter { @Composable - override fun MoleculePresenterScope.present( - lastState: SetupInvoicePayoutUiState, - ): SetupInvoicePayoutUiState { + override fun MoleculePresenterScope.present( + lastState: SetupInvoicePayinUiState, + ): SetupInvoicePayinUiState { var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } var showSuccessSnackBar by remember { mutableStateOf(false) } @@ -58,7 +58,7 @@ internal class SetupInvoicePayoutPresenter( LaunchedEffect(connectIteration) { isLoading = true errorMessage = null - setupInvoicePayoutUseCase.invoke().fold( + setupInvoicePayinUseCase.invoke().fold( ifLeft = { isLoading = false errorMessage = it.message ?: "Something went wrong, please try again" @@ -75,20 +75,20 @@ internal class SetupInvoicePayoutPresenter( CollectEvents { event -> when (event) { - SetupInvoicePayoutEvent.Connect -> { + SetupInvoicePayinEvent.Connect -> { if (!isLoading) { shouldConnect = true connectIteration++ } } - SetupInvoicePayoutEvent.ShowedSnackBar -> { - backstack.popUpTo(inclusive = true) + SetupInvoicePayinEvent.ShowedSnackBar -> { + backstack.popUpTo(inclusive = true) } } } - return SetupInvoicePayoutUiState( + return SetupInvoicePayinUiState( isLoading = isLoading, errorMessage = errorMessage, showSuccessSnackBar = showSuccessSnackBar, diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/QRCode.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/QRCode.kt new file mode 100644 index 0000000000..4d65e4355a --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/QRCode.kt @@ -0,0 +1,59 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import android.annotation.SuppressLint +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.BitmapPainter +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@SuppressLint("ProduceStateDoesNotAssignValue") +@Composable +internal fun QRCode(token: String, modifier: Modifier = Modifier) { + var intSize: IntSize? by remember { mutableStateOf(null) } + val painter by produceState(ColorPainter(Color.Transparent), intSize, token) { + val size = intSize + if (size == null) { + value = ColorPainter(Color.Transparent) + return@produceState + } + val bitmapPainter: BitmapPainter = withContext(Dispatchers.Default) { + val bitMatrix: BitMatrix = QRCodeWriter().encode( + token, + BarcodeFormat.QR_CODE, + size.width, + size.height, + ) + val bitmap = Bitmap.createBitmap(size.width, size.height, Bitmap.Config.RGB_565) + for (x in 0 until size.width) { + for (y in 0 until size.height) { + val color = if (bitMatrix.get(x, y)) android.graphics.Color.BLACK else android.graphics.Color.WHITE + bitmap.setPixel(x, y, color) + } + } + BitmapPainter(bitmap.asImageBitmap()) + } + value = bitmapPainter + } + Image( + painter, + null, + modifier.onSizeChanged { intSize = it }, + ) +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/RecurringSwishExplanationBottomSheet.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/RecurringSwishExplanationBottomSheet.kt new file mode 100644 index 0000000000..99032ae3b8 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/RecurringSwishExplanationBottomSheet.kt @@ -0,0 +1,119 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import androidx.compose.foundation.layout.Arrangement +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.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.HedvigBottomSheet +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.api.HedvigBottomSheetState +import hedvig.resources.PAYMENT_SWISH_EXPLANATION_BUTTON +import hedvig.resources.Res +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun RecurringSwishExplanationBottomSheet(sheetState: HedvigBottomSheetState, onLearnMore: () -> Unit) { + HedvigBottomSheet(sheetState) { + RecurringSwishExplanationContent( + onLearnMore = onLearnMore, + onDismiss = { sheetState.dismiss() }, + ) + } +} + +@Composable +private fun RecurringSwishExplanationContent(onLearnMore: () -> Unit, onDismiss: () -> Unit) { + HedvigText( + text = stringResource(Res.string.PAYMENT_SWISH_EXPLANATION_BUTTON), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 24.dp), + ) + Column( + verticalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.fillMaxWidth(), + ) { + ExplanationItem( + // TODO: Add "Paid automatically" / "Betalas automatiskt" to Lokalise + title = "Paid automatically", + // TODO: Add "Your premium is then charged automatically each month, no monthly approval needed." / + // "Din premie dras sedan automatiskt varje månad, utan att du behöver godkänna varje gång." to Lokalise + description = "Your premium is then charged automatically each month, no monthly approval needed.", + ) + ExplanationItem( + // TODO: Add "Get paid instantly" / "Få betalt direkt" to Lokalise + title = "Get paid instantly", + // TODO: Add "When a claim is approved, your compensation lands in your account right away via Swish." / + // "När en skadeanmälan godkänns landar din ersättning direkt på ditt konto via Swish." to Lokalise + description = "When a claim is approved, your compensation lands in your account right away via Swish.", + ) + ExplanationItem( + // TODO: Add "Get notified" / "Få påminnelser" to Lokalise + title = "Get notified", + // TODO: Add "We'll send you a reminder a few days before each monthly payment." / + // "Vi skickar en påminnelse några dagar före varje månadsbetalning." to Lokalise + description = "We'll send you a reminder a few days before each monthly payment.", + ) + ExplanationItem( + // TODO: Add "Change anytime" / "Ändra när du vill" to Lokalise + title = "Change anytime", + // TODO: Add "You can change or remove Swish anytime in Settings." / + // "Du kan ändra eller ta bort Swish när som helst i inställningarna." to Lokalise + description = "You can change or remove Swish anytime in Settings.", + ) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + // TODO: Add "Learn more" / "Läs mer" to Lokalise + text = "Learn more", + onClick = onLearnMore, + enabled = true, + buttonStyle = ButtonDefaults.ButtonStyle.Secondary, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + HedvigButton( + // TODO: Add "Got it" / "Okej" to Lokalise + text = "Got it", + onClick = onDismiss, + enabled = true, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(32.dp)) +} + +@Composable +private fun ExplanationItem(title: String, description: String) { + Column(Modifier.fillMaxWidth()) { + HedvigText(title) + HedvigText(text = description, color = HedvigTheme.colorScheme.textSecondary) + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewRecurringSwishExplanationContent() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + Column( + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) { + RecurringSwishExplanationContent(onLearnMore = {}, onDismiss = {}) + } + } + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinDestination.kt new file mode 100644 index 0000000000..c5c81fc84b --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinDestination.kt @@ -0,0 +1,303 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.OffsetMapping +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.design.system.hedvig.GlobalSnackBarState +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigNotificationCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextField +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodTile +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.colored.Swish +import com.hedvig.android.design.system.hedvig.rememberHedvigBottomSheetState +import com.hedvig.android.design.system.hedvig.show +import com.hedvig.android.feature.payin.account.data.SwishSetupOrder +import com.hedvig.android.feature.payin.account.ui.components.formatSwishPhoneNumber +import hedvig.resources.CONTACT_INFO_CHANGES_SAVED +import hedvig.resources.GENERAL_CONFIRM +import hedvig.resources.ODYSSEY_PHONE_NUMBER_LABEL +import hedvig.resources.PAYMENT_SWISH_EXPLANATION_BUTTON +import hedvig.resources.PAYMENT_SWISH_SUBTITLE +import hedvig.resources.PAYMENT_SWISH_TITLE +import hedvig.resources.Res +import hedvig.resources.something_went_wrong +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SetupSwishPayinDestination( + viewModel: SetupSwishPayinViewModel, + globalSnackBarState: GlobalSnackBarState, + onSuccessfullyConnected: () -> Unit, + navigateToApproval: (order: SwishSetupOrder, phoneNumber: String) -> Unit, + navigateUp: () -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val orderToApprove = uiState.orderToApprove + LaunchedEffect(orderToApprove) { + if (orderToApprove != null) navigateToApproval(orderToApprove, uiState.phoneNumber) + } + SetupSwishPayinScreen( + uiState = uiState, + globalSnackBarState = globalSnackBarState, + onSave = { viewModel.emit(SetupSwishPayoutEvent.Save) }, + showedSnackBar = { + viewModel.emit(SetupSwishPayoutEvent.ShowedSnackBar) + onSuccessfullyConnected() + }, + navigateUp = navigateUp, + updateText = { + viewModel.emit(SetupSwishPayoutEvent.UpdateText(it)) + }, + ) +} + +@Composable +private fun SetupSwishPayinScreen( + uiState: SetupSwishPayoutUiState, + globalSnackBarState: GlobalSnackBarState, + onSave: () -> Unit, + showedSnackBar: () -> Unit, + navigateUp: () -> Unit, + updateText: (String) -> Unit, +) { + val changesSaved = stringResource(Res.string.CONTACT_INFO_CHANGES_SAVED) + LaunchedEffect(uiState.showSuccessSnackBar) { + if (!uiState.showSuccessSnackBar) return@LaunchedEffect + globalSnackBarState.show(changesSaved, NotificationPriority.Campaign) + showedSnackBar() + } + + val explanationSheetState = rememberHedvigBottomSheetState() + RecurringSwishExplanationBottomSheet( + sheetState = explanationSheetState, + onLearnMore = { + // TODO: point this at the recurring-Swish article once we know where it lives. + }, + ) + + HedvigScaffold( + topAppBarText = null, + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + Spacer(Modifier.height(8.dp)) + FlowHeading( + title = stringResource(Res.string.PAYMENT_SWISH_TITLE), + description = stringResource(Res.string.PAYMENT_SWISH_SUBTITLE), + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PaymentMethodTile( + modifier = Modifier.align(Alignment.CenterHorizontally), + mark = { + Image(HedvigIcons.Swish, EmptyContentDescription, Modifier.size(PaymentMethodMarkSize)) + }, + ) + Spacer(Modifier.weight(1f)) + if (uiState.error != null) { + HedvigNotificationCard( + message = uiState.error.message ?: stringResource(Res.string.something_went_wrong), + priority = NotificationPriority.Error, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + } + EnterPhoneNumberSection( + uiState = uiState, + onSave = onSave, + onLearnMoreAboutRecurringSwish = { explanationSheetState.show() }, + updateText = updateText, + ) + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun ColumnScope.EnterPhoneNumberSection( + uiState: SetupSwishPayoutUiState, + onSave: () -> Unit, + onLearnMoreAboutRecurringSwish: () -> Unit, + updateText: (String) -> Unit, +) { + val focusManager = LocalFocusManager.current + HedvigText( + text = stringResource(Res.string.PAYMENT_SWISH_EXPLANATION_BUTTON), + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondary, + textAlign = TextAlign.Center, + textDecoration = TextDecoration.Underline, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .clip(HedvigTheme.shapes.cornerSmall) + .clickable(onClick = onLearnMoreAboutRecurringSwish) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + Spacer(Modifier.height(16.dp)) + var input by remember { mutableStateOf(uiState.phoneNumber) } + val visualTransformation = SwishPhoneNumberVisualTransformation( + mask = "000-000-00-00", + maskColor = HedvigTheme.colorScheme.textTertiary, + ) + HedvigTextField( + text = input, + labelText = stringResource(Res.string.ODYSSEY_PHONE_NUMBER_LABEL), + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Phone), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + visualTransformation = visualTransformation, + errorState = HedvigTextFieldDefaults.ErrorState.NoError, + interactionSource = remember { MutableInteractionSource() }, + onValueChange = { + val digitsOnly = it.filter { char -> char.isDigit() } + if (digitsOnly.length <= 15) { + updateText(digitsOnly) + input = digitsOnly + } + }, + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.GENERAL_CONFIRM), + onClick = { + focusManager.clearFocus() + onSave() + }, + enabled = !uiState.isLoading && + uiState.phoneNumber.length >= 8 && + uiState.phoneNumber.length <= 15, + isLoading = uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) +} + +private class SwishPhoneNumberVisualTransformation( + private val mask: String, + private val maskColor: Color, +) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val trimmed = if (text.text.length >= 15) text.text.substring(0..14) else text.text + + val annotatedString = buildAnnotatedString { + append(formatSwishPhoneNumber(trimmed)) + withStyle(SpanStyle(color = maskColor)) { + append(mask.takeLast((mask.length - length).coerceAtLeast(0))) + } + } + + val personalNumberOffsetTranslator = object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int { + return when { + offset <= 2 -> offset + offset <= 5 -> offset + 1 + offset <= 7 -> offset + 2 + else -> offset + 3 + } + } + + override fun transformedToOriginal(offset: Int): Int { + return when { + offset <= 3 -> offset + offset <= 7 -> offset - 1 + offset <= 10 -> offset - 2 + else -> offset - 3 + }.coerceAtMost(text.length) + } + } + return TransformedText(annotatedString, personalNumberOffsetTranslator) + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewSetupSwishPayinScreen( + @PreviewParameter(SetupSwishPayinUiStateProvider::class) uiState: SetupSwishPayoutUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + SetupSwishPayinScreen( + uiState = uiState, + globalSnackBarState = GlobalSnackBarState(), + onSave = {}, + showedSnackBar = {}, + navigateUp = {}, + updateText = {}, + ) + } + } +} + +private class SetupSwishPayinUiStateProvider : CollectionPreviewParameterProvider( + listOf( + SetupSwishPayoutUiState( + phoneNumber = "287334432273", + isLoading = false, + error = null, + showSuccessSnackBar = false, + ), + SetupSwishPayoutUiState( + phoneNumber = "", + isLoading = false, + error = ErrorMessage(), + showSuccessSnackBar = false, + ), + SetupSwishPayoutUiState( + phoneNumber = "837286428", + isLoading = true, + error = null, + showSuccessSnackBar = false, + ), + ), +) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinViewModel.kt new file mode 100644 index 0000000000..21e4910b9e --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SetupSwishPayinViewModel.kt @@ -0,0 +1,121 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.ErrorMessage +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.feature.payin.account.data.SetupSwishPayinUseCase +import com.hedvig.android.feature.payin.account.data.SetupSwishResponse +import com.hedvig.android.feature.payin.account.data.SwishSetupOrder +import com.hedvig.android.feature.payin.account.data.order +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Inject + +@Inject +@HedvigViewModel(ActivityRetainedScope::class) +internal class SetupSwishPayinViewModel( + setupSwishPayoutUseCase: SetupSwishPayinUseCase, +) : MoleculeViewModel( + SetupSwishPayoutUiState( + phoneNumber = "", + isLoading = false, + error = null, + showSuccessSnackBar = false, + orderToApprove = null, + ), + SetupSwishPayoutPresenter(setupSwishPayoutUseCase), + ) + +internal sealed interface SetupSwishPayoutEvent { + data object Save : SetupSwishPayoutEvent + + data object ShowedSnackBar : SetupSwishPayoutEvent + + data class UpdateText(val newText: String) : SetupSwishPayoutEvent +} + +internal data class SetupSwishPayoutUiState( + val showSuccessSnackBar: Boolean, + /** Set once the setup needs approving in the Swish app, which the caller moves on to. */ + val orderToApprove: SwishSetupOrder? = null, + val phoneNumber: String, + val isLoading: Boolean, + val error: ErrorMessage?, +) + +internal class SetupSwishPayoutPresenter( + private val setupSwishPayoutUseCase: SetupSwishPayinUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: SetupSwishPayoutUiState, + ): SetupSwishPayoutUiState { + var phoneNumberState by remember { mutableStateOf(lastState.phoneNumber) } + var isLoading by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var showSuccessSnackBar by remember { mutableStateOf(false) } + var saveIteration by remember { mutableStateOf(null) } + var orderToApprove by remember { mutableStateOf(null) } + + val currentSave = saveIteration + if (currentSave != null) { + LaunchedEffect(currentSave) { + isLoading = true + errorMessage = null + setupSwishPayoutUseCase.invoke(phoneNumberState).fold( + ifLeft = { + isLoading = false + errorMessage = it + saveIteration = null + }, + ifRight = { result -> + isLoading = false + saveIteration = null + val order = result.order + when { + order != null -> orderToApprove = order + + // A setup that needs no approving is already done, so there is nothing to wait on. + result is SetupSwishResponse.Success -> showSuccessSnackBar = true + + else -> errorMessage = (result as? SetupSwishResponse.Failure)?.error ?: ErrorMessage() + } + }, + ) + } + } + + CollectEvents { event -> + when (event) { + SetupSwishPayoutEvent.Save -> { + if (!isLoading) { + saveIteration = phoneNumberState + } + } + + SetupSwishPayoutEvent.ShowedSnackBar -> { + showSuccessSnackBar = false + } + + is SetupSwishPayoutEvent.UpdateText -> { + phoneNumberState = event.newText + } + } + } + + return SetupSwishPayoutUiState( + phoneNumber = phoneNumberState, + isLoading = isLoading, + error = errorMessage, + showSuccessSnackBar = showSuccessSnackBar, + orderToApprove = orderToApprove, + ) + } +} diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishAppHandover.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishAppHandover.kt new file mode 100644 index 0000000000..e192746acd --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishAppHandover.kt @@ -0,0 +1,90 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.core.net.toUri +import com.hedvig.android.logger.LogPriority +import com.hedvig.android.logger.logcat + +/** + * Hands the member over to the Swish app to approve a payin setup, following + * https://developer.swish.nu/documentation/guides/trigger-the-swish-app. + * + * [isSwishInstalled] is what the approval screen consults before handing over on its own. [open] is + * safe to call either way: with no Swish app on the device it falls back to opening the url. + */ +@Stable +internal interface SwishAppHandover { + val isSwishInstalled: Boolean + + fun open(approvalUrl: String) +} + +/** + * @param allowSandboxApp whether the Swish sandbox app counts as installed. Only it can approve the + * orders the staging backend issues, so builds pointing there accept it in place of the real app. + */ +@Composable +internal fun rememberSwishAppHandover(allowSandboxApp: Boolean, openUrl: (String) -> Unit): SwishAppHandover { + val context = LocalContext.current + return remember(context, allowSandboxApp, openUrl) { + SwishAppHandoverImpl(context, allowSandboxApp, openUrl) + } +} + +@Stable +private class SwishAppHandoverImpl( + private val context: Context, + allowSandboxApp: Boolean, + private val openUrl: (String) -> Unit, +) : SwishAppHandover { + private val installedPackage: String? = listOfNotNull( + SwishPackageName, + SwishSandboxPackageName.takeIf { allowSandboxApp }, + ).firstOrNull { context.isPackageInstalled(it) }.also { + logcat { "Resolved Swish app for payin approval: ${it ?: "none installed"}" } + } + + override val isSwishInstalled: Boolean + get() = installedPackage != null + + override fun open(approvalUrl: String) { + val packageName = installedPackage + if (packageName == null) { + openUrl(approvalUrl) + return + } + // Addressing the intent at Swish keeps the approval out of a browser or an app chooser. + val intent = Intent(Intent.ACTION_VIEW, approvalUrl.toUri()).setPackage(packageName) + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + logcat(LogPriority.WARN, e) { "$packageName would not take the Swish approval url" } + openUrl(approvalUrl) + } + } +} + +private fun Context.isPackageInstalled(packageName: String): Boolean { + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + packageManager.getPackageInfo(packageName, PackageManager.PackageInfoFlags.of(0)) + } else { + @Suppress("DEPRECATION") + packageManager.getPackageInfo(packageName, 0) + } + true + } catch (e: PackageManager.NameNotFoundException) { + false + } +} + +private const val SwishPackageName = "se.bankgirot.swish" +private const val SwishSandboxPackageName = "se.bankgirot.swish.sandbox" diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusDestination.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusDestination.kt new file mode 100644 index 0000000000..11f07bb198 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusDestination.kt @@ -0,0 +1,239 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.PaymentMethodHandoverIllustration +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodTileBadge +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.design.system.hedvig.icon.ArrowNorthEast +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.Close +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.colored.Swish +import hedvig.resources.GENERAL_RETRY +import hedvig.resources.PAYMENT_CHANGE_METHOD_BUTTON +import hedvig.resources.PAYMENT_OPEN_SWISH_BUTTON +import hedvig.resources.PAYMENT_SWISH_APPROVE_TITLE +import hedvig.resources.PAYMENT_SWISH_FAILURE_TITLE +import hedvig.resources.PAYMENT_SWISH_SUCCESS_SUBTITLE +import hedvig.resources.PAYMENT_SWISH_SUCCESS_TITLE +import hedvig.resources.Res +import hedvig.resources.general_cancel_button +import hedvig.resources.general_continue_button +import hedvig.resources.something_went_wrong +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SwishPayinStatusDestination( + viewModel: SwishPayinStatusViewModel, + allowSandboxSwishApp: Boolean, + navigateUp: () -> Unit, + navigateBack: () -> Unit, + finishSwishSetup: () -> Unit, + changePaymentMethod: () -> Unit, + openUrl: (String) -> Unit, +) { + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val swishAppHandover = rememberSwishAppHandover(allowSandboxSwishApp, openUrl) + + val pendingApproval = uiState as? SwishPayinStatusUiState.PendingApproval + val urlToAutoOpen = pendingApproval?.redirectUrl?.takeIf { pendingApproval.allowAutoOpen } + LaunchedEffect(urlToAutoOpen, swishAppHandover) { + if (urlToAutoOpen == null) return@LaunchedEffect + // Without the app there is nothing to hand over to, so the member gets the button instead. + if (!swishAppHandover.isSwishInstalled) return@LaunchedEffect + viewModel.emit(SwishPayinStatusEvent.DidOpenSwishApp) + swishAppHandover.open(urlToAutoOpen) + } + + SwishPayinStatusScreen( + uiState = uiState, + navigateUp = navigateUp, + onCancel = navigateBack, + onContinue = finishSwishSetup, + onRetry = { viewModel.emit(SwishPayinStatusEvent.Retry) }, + onChangePaymentMethod = changePaymentMethod, + openUrl = swishAppHandover::open, + ) +} + +@Composable +private fun SwishPayinStatusScreen( + uiState: SwishPayinStatusUiState, + navigateUp: () -> Unit, + onCancel: () -> Unit, + onContinue: () -> Unit, + onRetry: () -> Unit, + onChangePaymentMethod: () -> Unit, + openUrl: (String) -> Unit, +) { + HedvigScaffold( + topAppBarText = null, + navigateUp = navigateUp, + modifier = Modifier.fillMaxSize(), + ) { + Spacer(Modifier.height(8.dp)) + FlowHeading( + title = when (uiState) { + is SwishPayinStatusUiState.PendingApproval -> stringResource(Res.string.PAYMENT_SWISH_APPROVE_TITLE) + SwishPayinStatusUiState.Connected -> stringResource(Res.string.PAYMENT_SWISH_SUCCESS_TITLE) + is SwishPayinStatusUiState.Failed -> stringResource(Res.string.PAYMENT_SWISH_FAILURE_TITLE) + }, + description = when (uiState) { + is SwishPayinStatusUiState.PendingApproval -> null + SwishPayinStatusUiState.Connected -> stringResource(Res.string.PAYMENT_SWISH_SUCCESS_SUBTITLE) + is SwishPayinStatusUiState.Failed -> uiState.message ?: stringResource(Res.string.something_went_wrong) + }, + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PaymentMethodHandoverIllustration( + modifier = Modifier.align(Alignment.CenterHorizontally), + destinationBadge = { SetupStatusBadge(uiState) }, + mark = { Image(HedvigIcons.Swish, EmptyContentDescription, Modifier.size(PaymentMethodMarkSize)) }, + ) + Spacer(Modifier.weight(1f)) + when (uiState) { + is SwishPayinStatusUiState.PendingApproval -> { + HedvigButton( + text = stringResource(Res.string.PAYMENT_OPEN_SWISH_BUTTON), + onClick = { openUrl(uiState.redirectUrl) }, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(Res.string.general_cancel_button), + onClick = onCancel, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + SwishPayinStatusUiState.Connected -> { + HedvigText( + // TODO: Add "You can change payment method later" / "Du kan byta betalningsmetod senare" + // to Lokalise + text = "You can change payment method later", + style = HedvigTheme.typography.label, + color = HedvigTheme.colorScheme.textSecondaryTranslucent, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.general_continue_button), + onClick = onContinue, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + + is SwishPayinStatusUiState.Failed -> { + HedvigButton( + text = stringResource(Res.string.GENERAL_RETRY), + onClick = onRetry, + enabled = !uiState.isRetrying, + isLoading = uiState.isRetrying, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(Res.string.PAYMENT_CHANGE_METHOD_BUTTON), + onClick = onChangePaymentMethod, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + } + Spacer(Modifier.height(16.dp)) + } +} + +/** Marks the Hedvig symbol with where the setup has got to: handed over, landed, or rejected. */ +@Composable +private fun SetupStatusBadge(uiState: SwishPayinStatusUiState) { + when (uiState) { + is SwishPayinStatusUiState.PendingApproval -> PaymentMethodTileBadge( + icon = HedvigIcons.ArrowNorthEast, + containerColor = HedvigTheme.colorScheme.signalBlueElement, + contentColor = HedvigTheme.colorScheme.fillWhite, + ) + + SwishPayinStatusUiState.Connected -> PaymentMethodTileBadge( + icon = HedvigIcons.Checkmark, + containerColor = HedvigTheme.colorScheme.signalGreenElement, + contentColor = HedvigTheme.colorScheme.fillWhite, + ) + + is SwishPayinStatusUiState.Failed -> PaymentMethodTileBadge( + icon = HedvigIcons.Close, + containerColor = HedvigTheme.colorScheme.signalRedElement, + contentColor = HedvigTheme.colorScheme.fillWhite, + ) + } +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewSwishPayinStatusScreen( + @PreviewParameter(SwishPayinStatusUiStateProvider::class) uiState: SwishPayinStatusUiState, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + SwishPayinStatusScreen( + uiState = uiState, + navigateUp = {}, + onCancel = {}, + onContinue = {}, + onRetry = {}, + onChangePaymentMethod = {}, + openUrl = {}, + ) + } + } +} + +private class SwishPayinStatusUiStateProvider : CollectionPreviewParameterProvider( + listOf( + SwishPayinStatusUiState.PendingApproval("https://swish"), + SwishPayinStatusUiState.Connected, + SwishPayinStatusUiState.Failed(null), + SwishPayinStatusUiState.Failed("The connection was declined in the Swish app", isRetrying = true), + ), +) diff --git a/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusViewModel.kt b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusViewModel.kt new file mode 100644 index 0000000000..630e34eba8 --- /dev/null +++ b/app/feature/feature-payin-account/src/main/kotlin/com/hedvig/android/feature/payin/account/ui/setupswish/SwishPayinStatusViewModel.kt @@ -0,0 +1,146 @@ +package com.hedvig.android.feature.payin.account.ui.setupswish + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.hedvig.android.core.common.di.ActivityRetainedScope +import com.hedvig.android.core.common.di.HedvigViewModel +import com.hedvig.android.feature.payin.account.data.GetSwishPayinSetupStatusUseCase +import com.hedvig.android.feature.payin.account.data.SetupSwishPayinUseCase +import com.hedvig.android.feature.payin.account.data.SetupSwishResponse +import com.hedvig.android.feature.payin.account.data.SwishPayinSetupStatus +import com.hedvig.android.feature.payin.account.data.SwishSetupOrder +import com.hedvig.android.feature.payin.account.data.order +import com.hedvig.android.molecule.public.MoleculePresenter +import com.hedvig.android.molecule.public.MoleculePresenterScope +import com.hedvig.android.molecule.public.MoleculeViewModel +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +private val PollInterval = 2.seconds + +@AssistedInject +@HedvigViewModel(ActivityRetainedScope::class) +internal class SwishPayinStatusViewModel( + @Assisted successUrl: String, + @Assisted orderId: String, + @Assisted phoneNumber: String, + getSwishPayinSetupStatusUseCase: GetSwishPayinSetupStatusUseCase, + setupSwishPayinUseCase: SetupSwishPayinUseCase, +) : MoleculeViewModel( + initialState = SwishPayinStatusUiState.PendingApproval(successUrl), + presenter = SwishPayinStatusPresenter( + initialOrder = SwishSetupOrder(successUrl, orderId), + phoneNumber = phoneNumber, + getSwishPayinSetupStatusUseCase = getSwishPayinSetupStatusUseCase, + setupSwishPayinUseCase = setupSwishPayinUseCase, + ), + ) + +internal sealed interface SwishPayinStatusEvent { + data object Retry : SwishPayinStatusEvent + + data object DidOpenSwishApp : SwishPayinStatusEvent +} + +internal sealed interface SwishPayinStatusUiState { + /** + * @param allowAutoOpen whether the screen should still hand the member over to the Swish app + * without being asked. Each new order starts out allowing it; it is spent on the first handover + * so that coming back from Swish, or a rotation, does not bounce the member out again. + */ + data class PendingApproval(val redirectUrl: String, val allowAutoOpen: Boolean = true) : SwishPayinStatusUiState + + data object Connected : SwishPayinStatusUiState + + data class Failed(val message: String?, val isRetrying: Boolean = false) : SwishPayinStatusUiState +} + +internal class SwishPayinStatusPresenter( + private val initialOrder: SwishSetupOrder, + private val phoneNumber: String, + private val getSwishPayinSetupStatusUseCase: GetSwishPayinSetupStatusUseCase, + private val setupSwishPayinUseCase: SetupSwishPayinUseCase, +) : MoleculePresenter { + @Composable + override fun MoleculePresenterScope.present( + lastState: SwishPayinStatusUiState, + ): SwishPayinStatusUiState { + var order by remember { mutableStateOf(initialOrder) } + var uiState by remember { mutableStateOf(lastState) } + var retryOnFailIteration by remember { mutableIntStateOf(0) } + + // Keyed on the order, so a retry's new order restarts the polling against it. + LaunchedEffect(order) { + uiState = SwishPayinStatusUiState.PendingApproval(order.successUrl) + while (isActive) { + when (val status = getSwishPayinSetupStatusUseCase.invoke(order.orderId).getOrNull()) { + SwishPayinSetupStatus.Active -> { + uiState = SwishPayinStatusUiState.Connected + break + } + + is SwishPayinSetupStatus.Failed -> { + uiState = SwishPayinStatusUiState.Failed(status.message) + break + } + + // Still pending, or a transient failure to reach the backend; either way keep waiting, + // since the member can leave the screen themselves. + else -> { + delay(PollInterval) + } + } + } + } + + LaunchedEffect(retryOnFailIteration) { + if (retryOnFailIteration == 0) return@LaunchedEffect + uiState = SwishPayinStatusUiState.Failed(currentFailureMessage(uiState), isRetrying = true) + setupSwishPayinUseCase.invoke(phoneNumber).fold( + ifLeft = { error -> + uiState = SwishPayinStatusUiState.Failed(error.message) + }, + ifRight = { response -> + val newOrder = response.order + if (newOrder != null) { + order = newOrder + } else { + val message = (response as? SetupSwishResponse.Failure)?.error?.message + uiState = SwishPayinStatusUiState.Failed(message) + } + }, + ) + } + + CollectEvents { event -> + when (event) { + SwishPayinStatusEvent.DidOpenSwishApp -> { + val state = uiState + if (state is SwishPayinStatusUiState.PendingApproval) { + uiState = state.copy(allowAutoOpen = false) + } + } + + SwishPayinStatusEvent.Retry -> { + val state = uiState + if (state is SwishPayinStatusUiState.Failed && !state.isRetrying) { + retryOnFailIteration++ + } + } + } + } + + return uiState + } +} + +private fun currentFailureMessage(state: SwishPayinStatusUiState): String? = + (state as? SwishPayinStatusUiState.Failed)?.message diff --git a/app/feature/feature-payin-account/src/test/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKeySerializationTest.kt b/app/feature/feature-payin-account/src/test/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKeySerializationTest.kt new file mode 100644 index 0000000000..3aa68aada6 --- /dev/null +++ b/app/feature/feature-payin-account/src/test/kotlin/com/hedvig/android/feature/payin/account/navigation/SelectPrimaryPayinMethodKeySerializationTest.kt @@ -0,0 +1,51 @@ +package com.hedvig.android.feature.payin.account.navigation + +import assertk.assertThat +import assertk.assertions.isEqualTo +import com.hedvig.android.data.paying.member.InvoiceDelivery +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.navigation.common.HedvigNavKey +import kotlinx.serialization.PolymorphicSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule +import kotlinx.serialization.modules.polymorphic +import kotlinx.serialization.modules.subclass +import org.junit.Test + +internal class SelectPrimaryPayinMethodKeySerializationTest { + private val json = Json { + serializersModule = SerializersModule { + polymorphic(HedvigNavKey::class) { + subclass(SelectPrimaryPayinMethodKey::class) + subclass(PayinMethodDetailsKey::class) + } + } + } + + @Test + fun `key carrying every payin account type survives a serialization round trip`() { + val key = SelectPrimaryPayinMethodKey( + currentMethods = listOf( + PayinAccount.Trustly("8327", "91234124", "Swedbank", isPending = false, isDefault = true), + PayinAccount.SwishPayin("0709901232", isPending = true, isDefault = false), + PayinAccount.Invoice(InvoiceDelivery.Kivra, "a@b.com", isPending = false, isDefault = false), + PayinAccount.Invoice(null, null, isPending = false, isDefault = false), + ), + ) + + val encoded = json.encodeToString(PolymorphicSerializer(HedvigNavKey::class), key) + + assertThat(json.decodeFromString(PolymorphicSerializer(HedvigNavKey::class), encoded)).isEqualTo(key) + } + + @Test + fun `payin method details key survives a serialization round trip for every method id`() { + for (methodId in PayinMethodId.entries) { + val key = PayinMethodDetailsKey(methodId) + + val encoded = json.encodeToString(PolymorphicSerializer(HedvigNavKey::class), key) + + assertThat(json.decodeFromString(PolymorphicSerializer(HedvigNavKey::class), encoded)).isEqualTo(key) + } + } +} diff --git a/app/feature/feature-payments/build.gradle.kts b/app/feature/feature-payments/build.gradle.kts index 38e4fb97f0..d60c30b54b 100644 --- a/app/feature/feature-payments/build.gradle.kts +++ b/app/feature/feature-payments/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.designSystemHedvig) implementation(projects.featureFlags) implementation(projects.featureForeverNavigation) + implementation(projects.featurePayinAccountNavigation) implementation(projects.featurePaymentsNavigation) implementation(projects.languageCore) implementation(projects.languageData) diff --git a/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql b/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql index da34fe73c5..b5f15e22b8 100644 --- a/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql +++ b/app/feature/feature-payments/src/main/graphql/QueryManualChargeInfo.graphql @@ -11,12 +11,13 @@ query ManualChargeInfo { } } } - paymentInformation { - status - chargeMethod { - displayName - descriptor - paymentMethod + paymentMethods(version: 2) { + payinMethods { + ...MemberPaymentMethodFragment + } + availableMethods { + provider + supportsPayin } } activeContracts { diff --git a/app/feature/feature-payments/src/main/graphql/QueryMemberPaymentDetails.graphql b/app/feature/feature-payments/src/main/graphql/QueryMemberPaymentDetails.graphql deleted file mode 100644 index c4cd0d824f..0000000000 --- a/app/feature/feature-payments/src/main/graphql/QueryMemberPaymentDetails.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query MemberPaymentDetails { - currentMember { - paymentMethods { - payinMethods { - ...MemberPaymentMethodFragment - } - defaultPayinMethod { - ...MemberPaymentMethodFragment - } - chargingDay - } - } -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt index da7403e007..63a503a565 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/PreviewData.kt @@ -3,6 +3,7 @@ package com.hedvig.android.feature.payments import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney import com.hedvig.android.data.paying.member.MemberType +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId import com.hedvig.android.feature.payments.data.Discount import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.MemberCharge @@ -11,6 +12,7 @@ import com.hedvig.android.feature.payments.data.MemberPaymentChargeMethod import com.hedvig.android.feature.payments.data.PaymentConnection import com.hedvig.android.feature.payments.data.PaymentOverview import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge +import com.hedvig.android.feature.payments.data.PrimaryPayinMethod import kotlinx.datetime.LocalDate internal val periodsPreviewData = listOf( @@ -280,6 +282,7 @@ internal val paymentOverViewPreviewData: PaymentOverview memberChargeShortInfo = memberChargeShortInfo, ongoingCharges = listOf(OngoingCharge("id", LocalDate.fromEpochDays(401), UiMoney(200.0, UiCurrencyCode.SEK))), paymentConnection = PaymentConnection.Active, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), isManualChargeAllowed = ManualChargeToPrompt(UiMoney(200.0, UiCurrencyCode.SEK)), memberType = MemberType.STANDARD_MEMBER, ) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt index b693ee0128..2e7b278174 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetManualChargeInfoUseCase.kt @@ -12,15 +12,33 @@ import com.hedvig.android.apollo.safeExecute import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.common.di.AppScope import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.toPayinAccount import com.hedvig.android.logger.logcat import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import kotlinx.datetime.LocalDate import octopus.ManualChargeInfoQuery +import octopus.type.MemberPaymentProvider internal interface GetManualChargeInfoUseCase { - suspend fun invoke(): Either + suspend fun invoke(): Either +} + +/** + * Separates "we could not load it" (the [Either] left) from the backend's own answer that there is + * nothing here to charge, so a caller can keep a usable screen through a network blip without also + * keeping one whose charge has gone away. + */ +internal sealed interface ManualChargeInfoResult { + data class Chargeable(val info: ManualChargeInfo) : ManualChargeInfoResult + + /** + * `missedChargeIdToChargeManually` came back null, which per the schema means the latest charge + * either succeeded or the member may no longer settle it themselves. + */ + data object NoLongerChargeable : ManualChargeInfoResult } @ContributesBinding(AppScope::class) @@ -29,7 +47,7 @@ internal interface GetManualChargeInfoUseCase { internal class GetManualChargeInfoUseCaseImpl( private val apolloClient: ApolloClient, ) : GetManualChargeInfoUseCase { - override suspend fun invoke(): Either = either { + override suspend fun invoke(): Either = either { val currentMember = apolloClient.query(ManualChargeInfoQuery()) .fetchPolicy(FetchPolicy.NetworkOnly) .safeExecute(::ErrorMessage) @@ -44,24 +62,33 @@ internal class GetManualChargeInfoUseCaseImpl( if (showManualCharge == null) { logcat { "GetManualChargeInfoUseCaseImpl: missedChargeIdToChargeManually is null" } - raise(ErrorMessage()) + return@either ManualChargeInfoResult.NoLongerChargeable } val latestFailedPastCharge = currentMember.pastCharges .firstOrNull { it.id == showManualCharge } + // The backend named a charge to settle but did not return it, which is not a state the member + // can act on either way, so it stays a plain failure they can retry out of. if (latestFailedPastCharge == null) { logcat { "GetManualChargeInfoUseCaseImpl: latestFailedPastCharge is null" } raise(ErrorMessage()) } - ManualChargeInfo( - chargeId = latestFailedPastCharge.id, - missedDueDate = latestFailedPastCharge.date, - amountDue = UiMoney.fromMoneyFragment(latestFailedPastCharge.net), - bankAccountDisplayValue = currentMember.paymentInformation.chargeMethod?.displayName, - bankDescriptor = currentMember.paymentInformation.chargeMethod?.descriptor, - showCancellationWarning = showCancellationWarning, + val currentMethods = currentMember.paymentMethods.payinMethods.mapNotNull { it.toPayinAccount() } + + ManualChargeInfoResult.Chargeable( + ManualChargeInfo( + chargeId = latestFailedPastCharge.id, + missedDueDate = latestFailedPastCharge.date, + amountDue = UiMoney.fromMoneyFragment(latestFailedPastCharge.net), + currentMethods = currentMethods, + availablePayinMethods = currentMember.paymentMethods.availableMethods + .filter { it.supportsPayin } + .map { it.provider }, + primaryPayinMethod = currentMethods.firstOrNull { it.isDefault }, + showCancellationWarning = showCancellationWarning, + ), ) } } @@ -70,7 +97,9 @@ internal data class ManualChargeInfo( val chargeId: String?, val missedDueDate: LocalDate, val amountDue: UiMoney, - val bankDescriptor: String?, - val bankAccountDisplayValue: String?, + val currentMethods: List, + val availablePayinMethods: List, + /** The method the member is charged on, absent when none is connected or it is one we cannot show. */ + val primaryPayinMethod: PayinAccount?, val showCancellationWarning: Boolean, ) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetMemberPaymentsDetailsUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetMemberPaymentsDetailsUseCase.kt deleted file mode 100644 index 36de144197..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/GetMemberPaymentsDetailsUseCase.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.hedvig.android.feature.payments.data - -import arrow.core.Either -import arrow.core.raise.context.bind -import arrow.core.raise.context.either -import arrow.core.raise.context.raise -import com.apollographql.apollo.ApolloClient -import com.apollographql.cache.normalized.FetchPolicy -import com.apollographql.cache.normalized.fetchPolicy -import com.hedvig.android.apollo.ErrorMessage -import com.hedvig.android.apollo.safeExecute -import com.hedvig.android.core.common.ErrorMessage -import com.hedvig.android.core.common.di.AppScope -import com.hedvig.android.logger.LogPriority -import com.hedvig.android.logger.logcat -import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import octopus.MemberPaymentDetailsQuery -import octopus.fragment.MemberPaymentMethodFragment -import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodBankAccountDetailsDetails -import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodInvoiceDetailsDetails -import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodSwishDetailsDetails -import octopus.type.MemberPaymentMethodStatus -import octopus.type.MemberPaymentProvider -import octopus.type.PaymentMethodInvoiceDelivery - -internal interface GetMemberPaymentsDetailsUseCase { - suspend fun invoke(): Either -} - -@ContributesBinding(AppScope::class) -@SingleIn(AppScope::class) -@Inject -internal class GetMemberPaymentsDetailsUseCaseImpl( - private val apolloClient: ApolloClient, -) : GetMemberPaymentsDetailsUseCase { - override suspend fun invoke(): Either { - return either { - val result = apolloClient.query(MemberPaymentDetailsQuery()) - .fetchPolicy(FetchPolicy.NetworkOnly) - .safeExecute(::ErrorMessage) - .onLeft { - logcat(LogPriority.WARN) { "GetMemberPaymentsDetailsUseCase returned error: $it" } - } - .bind() - val paymentMethods = result.currentMember.paymentMethods - val payinMethod = paymentMethods.defaultPayinMethod - ?: paymentMethods.payinMethods.find { it.isDefault && it.status == MemberPaymentMethodStatus.ACTIVE } - if (payinMethod == null) { - logcat(LogPriority.WARN) { "GetMemberPaymentsDetailsUseCase no active default payin method" } - raise(ErrorMessage()) - } - val paymentMethod = payinMethod.provider.toPaymentMethod() - if (paymentMethod == null) { - logcat(LogPriority.WARN) { "GetMemberPaymentsDetailsUseCase unknown provider: ${payinMethod.provider}" } - raise(ErrorMessage()) - } - MemberPaymentsDetails( - paymentMethod = paymentMethod, - chargingDayInTheMonth = paymentMethods.chargingDay, - account = payinMethod.details?.toPaymentAccount(), - ) - } - } -} - -internal data class MemberPaymentsDetails( - val paymentMethod: PaymentMethod, - val chargingDayInTheMonth: Int?, - val account: PaymentAccount?, -) - -private fun MemberPaymentProvider.toPaymentMethod(): PaymentMethod? = when (this) { - MemberPaymentProvider.TRUSTLY -> PaymentMethod.TRUSTLY - MemberPaymentProvider.SWISH -> PaymentMethod.SWISH - MemberPaymentProvider.NORDEA -> PaymentMethod.NORDEA - MemberPaymentProvider.INVOICE -> PaymentMethod.INVOICE - MemberPaymentProvider.UNKNOWN__ -> null -} - -private fun MemberPaymentMethodFragment.Details.toPaymentAccount(): PaymentAccount? = when (this) { - is PaymentMethodInvoiceDetailsDetails -> when (delivery) { - PaymentMethodInvoiceDelivery.KIVRA -> PaymentAccount.Kivra - PaymentMethodInvoiceDelivery.MAIL -> email?.let(PaymentAccount::Email) - PaymentMethodInvoiceDelivery.UNKNOWN__ -> null - } - - is PaymentMethodSwishDetailsDetails -> PaymentAccount.PhoneNumber(phoneNumber) - - is PaymentMethodBankAccountDetailsDetails -> PaymentAccount.BankAccount(account, bank) - - else -> null -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentMethod.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentMethod.kt deleted file mode 100644 index 781843df04..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentMethod.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.hedvig.android.feature.payments.data - -internal enum class PaymentMethod { - TRUSTLY, - SWISH, - NORDEA, - INVOICE, -} - -internal sealed interface PaymentAccount { - data object Kivra : PaymentAccount - - data class Email(val email: String) : PaymentAccount - - data class PhoneNumber(val phoneNumber: String) : PaymentAccount - - data class BankAccount(val account: String, val bank: String) : PaymentAccount -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt index 3952ee0e73..5485cd6799 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PaymentOverview.kt @@ -8,6 +8,7 @@ data class PaymentOverview( val memberChargeShortInfo: MemberChargeShortInfo?, val ongoingCharges: List, val paymentConnection: PaymentConnection, + val primaryPayinMethod: PrimaryPayinMethod?, val isManualChargeAllowed: ManualChargeToPrompt?, val memberType: MemberType, ) { diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PrimaryPayinMethod.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PrimaryPayinMethod.kt new file mode 100644 index 0000000000..ce0e4d95c2 --- /dev/null +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/data/PrimaryPayinMethod.kt @@ -0,0 +1,70 @@ +package com.hedvig.android.feature.payments.data + +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId +import octopus.fragment.MemberPaymentMethodFragment +import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodBankAccountDetailsDetails +import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodInvoiceDetailsDetails +import octopus.fragment.MemberPaymentMethodFragment.PaymentMethodSwishDetailsDetails +import octopus.type.MemberPaymentProvider +import octopus.type.PaymentMethodInvoiceDelivery + +/** + * The method the member is currently charged with, summarised for the payments overview. + * + * [descriptor] is the method's account in the member's own terms — the Swish number, the masked bank account, or + * the invoice delivery channel. Invoice has nothing else worth showing, so the row uses it as the title there and + * as the subtitle for the other providers. + */ +data class PrimaryPayinMethod( + val id: PayinMethodId, + val descriptor: String?, +) + +fun MemberPaymentMethodFragment.toPrimaryPayinMethod(): PrimaryPayinMethod? { + val id = when (provider) { + MemberPaymentProvider.TRUSTLY -> PayinMethodId.Trustly + MemberPaymentProvider.SWISH -> PayinMethodId.Swish + MemberPaymentProvider.INVOICE -> PayinMethodId.Invoice + MemberPaymentProvider.NORDEA, MemberPaymentProvider.UNKNOWN__ -> return null + } + return PrimaryPayinMethod(id = id, descriptor = details?.descriptor()) +} + +private fun MemberPaymentMethodFragment.Details.descriptor(): String? = when (this) { + is PaymentMethodSwishDetailsDetails -> { + formatSwishPhoneNumber(phoneNumber) + } + + is PaymentMethodBankAccountDetailsDetails -> { + val lastFour = account.substringAfter('-', "").takeLast(4).takeIf { it.isNotBlank() } + if (lastFour == null) bank else "$bank ···· $lastFour" + } + + is PaymentMethodInvoiceDetailsDetails -> { + when (delivery) { + // TODO: Add "Kivra" / "Kivra" to Lokalise + PaymentMethodInvoiceDelivery.KIVRA -> "Kivra" + + // TODO: Add "Email" / "E-post" to Lokalise + PaymentMethodInvoiceDelivery.MAIL -> "Email" + + PaymentMethodInvoiceDelivery.UNKNOWN__ -> null + } + } + + else -> { + null + } +} + +private fun formatSwishPhoneNumber(phoneNumber: String): String { + val digits = phoneNumber.take(15) + val sb = StringBuilder() + for (i in digits.indices) { + sb.append(digits[i]) + if (i in setOf(2, 5, 7)) { + sb.append("-") + } + } + return sb.toString() +} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt index b438e28e9d..f627b5a8ed 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsDestination.kt @@ -9,9 +9,6 @@ internal data object PaymentHistoryKey : HedvigNavKey @Serializable internal data object DiscountsKey : HedvigNavKey -@Serializable -internal data object MemberPaymentDetailsKey : HedvigNavKey - @Serializable internal data object ManualChargeKey : HedvigNavKey diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt index 04f972ad28..eb77154011 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/navigation/PaymentsEntries.kt @@ -3,7 +3,10 @@ package com.hedvig.android.feature.payments.navigation import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope import com.hedvig.android.compose.ui.dropUnlessResumed +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.provider import com.hedvig.android.feature.forever.navigation.InviteFriendsKey +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId import com.hedvig.android.feature.payments.ui.details.PaymentDetailExplanationContent import com.hedvig.android.feature.payments.ui.details.PaymentDetailsDestination import com.hedvig.android.feature.payments.ui.details.PaymentDetailsViewModel @@ -14,9 +17,8 @@ import com.hedvig.android.feature.payments.ui.history.PaymentHistoryDestination import com.hedvig.android.feature.payments.ui.history.PaymentHistoryViewModel import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeDestination import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeSuccessDestination +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeUiState import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeViewModel -import com.hedvig.android.feature.payments.ui.memberpaymentdetails.MemberPaymentDetailsDestination -import com.hedvig.android.feature.payments.ui.memberpaymentdetails.MemberPaymentDetailsViewModel import com.hedvig.android.feature.payments.ui.payments.PaymentsDestination import com.hedvig.android.feature.payments.ui.payments.PaymentsViewModel import com.hedvig.android.navigation.common.HedvigNavKey @@ -30,7 +32,11 @@ import dev.zacsweers.metrox.viewmodel.metroViewModel fun EntryProviderScope.paymentsEntries( backstack: Backstack, navigateToConnectPayment: () -> Unit, + navigateToPayinAccount: () -> Unit, + navigateToPayinMethodDetails: (PayinMethodId) -> Unit, navigateToPayoutAccount: () -> Unit, + navigateToSelectPayinMethod: (availableProviders: List, currentProviders: List) -> Unit, + navigateToSelectPrimaryPayinMethod: (currentMethods: List) -> Unit, openConversation: () -> Unit, ) { entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { @@ -48,9 +54,7 @@ fun EntryProviderScope.paymentsEntries( onPaymentClicked = dropUnlessResumed { id: String? -> backstack.add(PaymentDetailsKey(id)) }, - onMemberPaymentDetailsClicked = dropUnlessResumed { - backstack.add(MemberPaymentDetailsKey) - }, + onPaymentMethodsClicked = dropUnlessResumed { navigateToPayinAccount() }, onOpenManualCharge = { backstack.add(ManualChargeKey) }, @@ -65,6 +69,17 @@ fun EntryProviderScope.paymentsEntries( onNavigateToPaymentDetails = dropUnlessResumed { chargeId: String -> backstack.add(PaymentDetailsKey(chargeId)) }, + onConnectPayinMethodClicked = dropUnlessResumed { + val info = (viewModel.uiState.value as? ManualChargeUiState.Success)?.manualChargeInfo + navigateToSelectPayinMethod( + info?.availablePayinMethods?.map { it.rawValue } ?: emptyList(), + info?.currentMethods?.map { it.provider.rawValue } ?: emptyList(), + ) + }, + onChoosePrimaryMethodClicked = dropUnlessResumed { + val info = (viewModel.uiState.value as? ManualChargeUiState.Success)?.manualChargeInfo + navigateToSelectPrimaryPayinMethod(info?.currentMethods ?: emptyList()) + }, openConversation = openConversation, ) } @@ -118,13 +133,4 @@ fun EntryProviderScope.paymentsEntries( }, ) } - - entry(metadata = NavSuiteSceneDecoratorStrategy.showNavBar()) { - val viewModel: MemberPaymentDetailsViewModel = metroViewModel() - MemberPaymentDetailsDestination( - viewModel, - onChangeBankAccount = navigateToConnectPayment, - navigateUp = backstack::navigateUp, - ) - } } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetShouldShowPayoutUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetShouldShowPayoutUseCase.kt deleted file mode 100644 index 03225cb5e5..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetShouldShowPayoutUseCase.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.hedvig.android.feature.payments.overview.data - -import arrow.core.Either -import arrow.core.raise.either -import arrow.core.right -import com.apollographql.apollo.ApolloClient -import com.apollographql.cache.normalized.FetchPolicy -import com.apollographql.cache.normalized.fetchPolicy -import com.hedvig.android.apollo.ErrorMessage -import com.hedvig.android.apollo.safeExecute -import com.hedvig.android.core.common.ErrorMessage -import dev.zacsweers.metro.Inject -import octopus.ShouldShowPayoutButtonQuery - -internal interface GetShouldShowPayoutUseCase { - suspend fun invoke(): Either -} - -/** - * We do not want to show the payout button at all when there is no payout method connected nor is there a possibility - * to add one in the member's current state - */ -@Inject -internal class GetShouldShowPayoutUseCaseImpl( - private val apolloClient: ApolloClient, -) : GetShouldShowPayoutUseCase { - override suspend fun invoke(): Either = either { - val result = apolloClient - .query(ShouldShowPayoutButtonQuery()) - .fetchPolicy(FetchPolicy.NetworkFirst) - .safeExecute(::ErrorMessage) - .bind() - - val paymentMethods = result.currentMember.paymentMethods - paymentMethods.availableMethods.any { it.supportsPayout } || - paymentMethods.defaultPayoutMethod != null || - paymentMethods.payoutMethods.isNotEmpty() - } -} - -@Inject -internal class GetShouldShowPayoutUseCaseDemo : GetShouldShowPayoutUseCase { - override suspend fun invoke(): Either = false.right() -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt index b93d061726..34fdc3a2b8 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/GetUpcomingPaymentUseCase.kt @@ -20,6 +20,7 @@ import com.hedvig.android.feature.payments.data.PaymentConnection import com.hedvig.android.feature.payments.data.PaymentOverview import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge import com.hedvig.android.feature.payments.data.toFailedCharge +import com.hedvig.android.feature.payments.data.toPrimaryPayinMethod import com.hedvig.android.logger.logcat import dev.zacsweers.metro.Inject import kotlin.time.Clock @@ -28,6 +29,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.toLocalDateTime import octopus.UpcomingPaymentQuery import octopus.fragment.MemberChargeFragment +import octopus.fragment.MemberPaymentMethodFragment import octopus.type.MemberChargeStatus import octopus.type.MemberPaymentMethodStatus @@ -72,6 +74,10 @@ internal data class GetUpcomingPaymentUseCaseImpl( val id = it.id ?: return@mapNotNull null OngoingCharge(id, it.date, UiMoney.fromMoneyFragment(it.net)) }, + primaryPayinMethod = result.currentMember.paymentMethods.let { paymentMethods -> + paymentMethods.defaultPayinMethod as MemberPaymentMethodFragment? + ?: paymentMethods.payinMethods.find { it.isDefault } + }?.toPrimaryPayinMethod(), paymentConnection = run { val paymentMethods = result.currentMember.paymentMethods val payinMethod = paymentMethods.defaultPayinMethod @@ -162,6 +168,7 @@ internal class GetUpcomingPaymentUseCaseDemo( ), emptyList(), PaymentConnection.Unknown, + primaryPayinMethod = null, isManualChargeAllowed = null, memberType = MemberType.STANDARD_MEMBER, ).right() diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/SwitchingGetShouldShowPayoutUseCase.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/SwitchingGetShouldShowPayoutUseCase.kt deleted file mode 100644 index 3979515a65..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/overview/data/SwitchingGetShouldShowPayoutUseCase.kt +++ /dev/null @@ -1,20 +0,0 @@ -package com.hedvig.android.feature.payments.overview.data - -import com.hedvig.android.core.common.di.AppScope -import com.hedvig.android.core.demomode.DemoManager -import com.hedvig.android.core.demomode.DemoSwitcher -import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import dev.zacsweers.metro.binding - -@Inject -@SingleIn(AppScope::class) -@ContributesBinding(AppScope::class, binding = binding()) -internal class SwitchingGetShouldShowPayoutUseCase( - override val demoManager: DemoManager, - override val prodImpl: GetShouldShowPayoutUseCaseImpl, - override val demoImpl: GetShouldShowPayoutUseCaseDemo, -) : GetShouldShowPayoutUseCase, DemoSwitcher() { - override suspend fun invoke() = pick().invoke() -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt index ffda4db0cc..c5807cc8c3 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeDestination.kt @@ -6,12 +6,19 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height 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.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -19,23 +26,34 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterProvider import com.hedvig.android.core.common.ErrorMessage import com.hedvig.android.core.uidata.UiCurrencyCode import com.hedvig.android.core.uidata.UiMoney +import com.hedvig.android.data.paying.member.PayinAccount +import com.hedvig.android.data.paying.member.PayinAccount.Invoice +import com.hedvig.android.data.paying.member.PayinAccount.SwishPayin +import com.hedvig.android.data.paying.member.PayinAccount.Trustly +import com.hedvig.android.data.paying.member.maskedAccountNumber import com.hedvig.android.design.system.hedvig.ButtonDefaults +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize +import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonStyle import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress import com.hedvig.android.design.system.hedvig.HedvigNotificationCard import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.HorizontalDivider import com.hedvig.android.design.system.hedvig.Icon import com.hedvig.android.design.system.hedvig.NotificationDefaults +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.AttentionRound import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.HedvigIcons @@ -43,10 +61,21 @@ import com.hedvig.android.design.system.hedvig.icon.WarningFilled import com.hedvig.android.design.system.hedvig.rememberHedvigDateTimeFormatter import com.hedvig.android.design.system.hedvig.rememberHedvigMonthDateTimeFormatter import com.hedvig.android.feature.payments.data.ManualChargeInfo -import hedvig.resources.BANK_PAYOUT_METHOD_CARD_TITLE +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeEvent.Refresh +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeEvent.Retry +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeEvent.TriggerCharge +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeUiState.Failure +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeUiState.Loading +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeUiState.NoLongerChargeable +import com.hedvig.android.feature.payments.ui.manualcharge.ManualChargeUiState.Success import hedvig.resources.GENERAL_ERROR_BODY import hedvig.resources.GENERAL_RETRY import hedvig.resources.MANUAL_CHARGE_CANCELLATION_WARNING +import hedvig.resources.PAYMENTS_ACCOUNT +import hedvig.resources.PAYMENTS_AUTOGIRO_LABEL +import hedvig.resources.PAYMENTS_BANK_LABEL +import hedvig.resources.PAYMENTS_INVOICE +import hedvig.resources.PAYMENTS_PAYMENT_METHOD import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_BODY import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_DUE_DATE import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_FINE_PRINT @@ -54,12 +83,18 @@ import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_PAY import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_SINCE import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_DETAILS_VIEW_DETAILS import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_TITLE +import hedvig.resources.PAYMENTS_SWISH_NUMBER +import hedvig.resources.PAYMENT_ADD_METHOD_BUTTON +import hedvig.resources.PAYMENT_CHOOSE_PRIMARY_BUTTON import hedvig.resources.Res +import hedvig.resources.Res.string import hedvig.resources.SELF_MANUAL_CHARGE_CHANGES_BEEN_MADE_TITLE import hedvig.resources.claim_status_detail_chat_button_description import hedvig.resources.payment_details_receipt_card_total import hedvig.resources.something_went_wrong +import hedvig.resources.swish import kotlinx.datetime.LocalDate +import octopus.type.MemberPaymentProvider import org.jetbrains.compose.resources.stringResource @Composable @@ -67,18 +102,31 @@ internal fun ManualChargeDestination( viewModel: ManualChargeViewModel, navigateUp: () -> Unit, onNavigateToPaymentDetails: (chargeId: String) -> Unit, + onConnectPayinMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, openConversation: () -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle() + var hasResumedOnce by rememberSaveable { mutableStateOf(false) } + LifecycleResumeEffect(Unit) { + if (hasResumedOnce) { + viewModel.emit(Refresh) + } else { + hasResumedOnce = true + } + onPauseOrDispose {} + } ManualChargeScreen( uiState = uiState.value, navigateUp = navigateUp, - reload = { viewModel.emit(ManualChargeEvent.Retry) }, + reload = { viewModel.emit(Retry) }, onNavigateToPaymentDetails = onNavigateToPaymentDetails, onTriggerPayment = { - viewModel.emit(ManualChargeEvent.TriggerCharge) + viewModel.emit(TriggerCharge) }, + onConnectPayinMethodClicked = onConnectPayinMethodClicked, + onChoosePrimaryMethodClicked = onChoosePrimaryMethodClicked, openConversation = openConversation, ) } @@ -91,27 +139,29 @@ private fun ManualChargeScreen( openConversation: () -> Unit, onNavigateToPaymentDetails: (chargeId: String) -> Unit, onTriggerPayment: () -> Unit, + onConnectPayinMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, ) { HedvigScaffold( navigateUp = navigateUp, - topAppBarText = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_TITLE), + topAppBarText = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_TITLE), ) { when (uiState) { - is ManualChargeUiState.Failure -> { + is Failure -> { val title = if (uiState.error.message != null) { - stringResource(Res.string.SELF_MANUAL_CHARGE_CHANGES_BEEN_MADE_TITLE) + stringResource(string.SELF_MANUAL_CHARGE_CHANGES_BEEN_MADE_TITLE) } else { - stringResource(Res.string.something_went_wrong) + stringResource(string.something_went_wrong) } val subTitle = if (uiState.error.message != null) { uiState.error.message } else { - stringResource(Res.string.GENERAL_ERROR_BODY) + stringResource(string.GENERAL_ERROR_BODY) } val buttonText = if (uiState.error.message != null) { - stringResource(Res.string.claim_status_detail_chat_button_description) + stringResource(string.claim_status_detail_chat_button_description) } else { - stringResource(Res.string.GENERAL_RETRY) + stringResource(string.GENERAL_RETRY) } val onButtonClick = if (uiState.error.message != null) openConversation else reload @@ -126,17 +176,32 @@ private fun ManualChargeScreen( ) } - ManualChargeUiState.Loading -> { + NoLongerChargeable -> { + HedvigErrorSection( + onButtonClick = openConversation, + Modifier + .weight(1f) + .fillMaxWidth(), + title = stringResource(string.SELF_MANUAL_CHARGE_CHANGES_BEEN_MADE_TITLE), + subTitle = null, + buttonText = stringResource(string.claim_status_detail_chat_button_description), + ) + } + + Loading -> { HedvigFullScreenCenterAlignedProgress( modifier = Modifier.weight(1f), ) } - is ManualChargeUiState.Success -> { + is Success -> { ManualChargeSuccessScreen( uiState, onNavigateToPaymentDetails = onNavigateToPaymentDetails, onTriggerPayment = onTriggerPayment, + onConnectPayinMethodClicked = onConnectPayinMethodClicked, + onChoosePrimaryMethodClicked = onChoosePrimaryMethodClicked, + modifier = Modifier.weight(1f), ) } } @@ -145,13 +210,22 @@ private fun ManualChargeScreen( @Composable private fun ManualChargeSuccessScreen( - uiState: ManualChargeUiState.Success, + uiState: Success, onNavigateToPaymentDetails: (chargeId: String) -> Unit, onTriggerPayment: () -> Unit, + onConnectPayinMethodClicked: () -> Unit, + onChoosePrimaryMethodClicked: () -> Unit, + modifier: Modifier = Modifier, ) { + val currentMethods = uiState.manualChargeInfo.currentMethods + val availablePayinMethods = uiState.manualChargeInfo.availablePayinMethods val dateTimeFormatter = rememberHedvigMonthDateTimeFormatter() val dateTimeFormatterWithYear = rememberHedvigDateTimeFormatter() - Column { + Column( + modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { Column( modifier = Modifier .padding( @@ -190,26 +264,26 @@ private fun ManualChargeSuccessScreen( Column(modifier = Modifier.weight(1f)) { HedvigText( text = stringResource( - Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_SINCE, + string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_SINCE, dateTimeFormatter.format(uiState.manualChargeInfo.missedDueDate), ), ) HedvigText( - text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_BODY), + text = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_BODY), color = HedvigTheme.colorScheme.textSecondary, ) } } if (uiState.manualChargeInfo.chargeId != null) { HedvigButton( - text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_VIEW_DETAILS), + text = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_VIEW_DETAILS), onClick = { onNavigateToPaymentDetails(uiState.manualChargeInfo.chargeId) }, enabled = true, modifier = Modifier.fillMaxWidth(), - buttonStyle = ButtonDefaults.ButtonStyle.Ghost, - buttonSize = ButtonDefaults.ButtonSize.Medium, + buttonStyle = ButtonStyle.Ghost, + buttonSize = ButtonSize.Medium, border = HedvigTheme.colorScheme.borderPrimary, ) } @@ -223,7 +297,7 @@ private fun ManualChargeSuccessScreen( horizontalArrangement = Arrangement.SpaceBetween, ) { HedvigText( - text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_DUE_DATE), + text = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_DUE_DATE), color = HedvigTheme.colorScheme.textSecondary, style = HedvigTheme.typography.label, ) @@ -233,22 +307,36 @@ private fun ManualChargeSuccessScreen( style = HedvigTheme.typography.label, ) } - if (uiState.manualChargeInfo.bankDescriptor != null) { - Spacer(Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - HedvigText( - text = stringResource(Res.string.BANK_PAYOUT_METHOD_CARD_TITLE), - color = HedvigTheme.colorScheme.textSecondary, - style = HedvigTheme.typography.label, - ) - HedvigText( - text = uiState.manualChargeInfo.bankDescriptor, - color = HedvigTheme.colorScheme.textSecondary, - style = HedvigTheme.typography.label, - ) + val method = uiState.manualChargeInfo.primaryPayinMethod + if (method != null) { + DetailRow( + label = stringResource(string.PAYMENTS_PAYMENT_METHOD), + value = when (method) { + is Trustly -> stringResource(string.PAYMENTS_AUTOGIRO_LABEL) + is SwishPayin -> stringResource(string.swish) + is Invoice -> stringResource(string.PAYMENTS_INVOICE) + }, + ) + when (method) { + is SwishPayin -> { + DetailRow( + label = stringResource(string.PAYMENTS_SWISH_NUMBER), + value = method.phoneNumber.orEmpty(), + ) + } + + is Trustly -> { + DetailRow( + label = stringResource(string.PAYMENTS_ACCOUNT), + value = method.maskedAccountNumber().orEmpty(), + ) + DetailRow( + label = stringResource(string.PAYMENTS_BANK_LABEL), + value = method.bankName.orEmpty(), + ) + } + + is Invoice -> {} } } } @@ -265,7 +353,7 @@ private fun ManualChargeSuccessScreen( verticalAlignment = Alignment.CenterVertically, ) { HedvigText( - text = stringResource(Res.string.payment_details_receipt_card_total), + text = stringResource(string.payment_details_receipt_card_total), ) HedvigText( text = uiState.manualChargeInfo.amountDue.toString(), @@ -274,17 +362,17 @@ private fun ManualChargeSuccessScreen( } HedvigButton( - text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_PAY, uiState.manualChargeInfo.amountDue), + text = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_PAY, uiState.manualChargeInfo.amountDue), onClick = onTriggerPayment, enabled = !uiState.payButtonLoading, - buttonSize = ButtonDefaults.ButtonSize.Medium, + buttonSize = ButtonSize.Medium, modifier = Modifier.fillMaxWidth(), isLoading = uiState.payButtonLoading, ) Spacer(modifier = Modifier.height(8.dp)) HedvigText( - text = stringResource(Res.string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_FINE_PRINT), + text = stringResource(string.PAYMENTS_PAYMENT_OVERDUE_DETAILS_FINE_PRINT), color = HedvigTheme.colorScheme.textSecondaryTranslucent, textAlign = TextAlign.Center, style = HedvigTheme.typography.label, @@ -295,20 +383,69 @@ private fun ManualChargeSuccessScreen( } if (uiState.manualChargeInfo.showCancellationWarning) { HedvigNotificationCard( - message = stringResource(Res.string.MANUAL_CHARGE_CANCELLATION_WARNING), - priority = NotificationDefaults.NotificationPriority.AttentionRound, + message = stringResource(string.MANUAL_CHARGE_CANCELLATION_WARNING), + priority = AttentionRound, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), ) Spacer(Modifier.height(16.dp)) } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.height(16.dp)) + if (availablePayinMethods.isNotEmpty()) { + HedvigButton( + text = stringResource(string.PAYMENT_ADD_METHOD_BUTTON), + onClick = onConnectPayinMethodClicked, + enabled = true, + buttonStyle = ButtonStyle.Secondary, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + if (currentMethods + .filter { !it.isPending } + .size > 1 + ) { + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + text = stringResource(string.PAYMENT_CHOOSE_PRIMARY_BUTTON), + onClick = onChoosePrimaryMethodClicked, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + } + Spacer(Modifier.height(16.dp)) + } +} + +@Composable +private fun DetailRow(label: String, value: String, modifier: Modifier = Modifier) { + Column(modifier) { + Spacer(Modifier.height(10.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + HedvigText( + text = label, + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + HedvigText( + text = value, + color = HedvigTheme.colorScheme.textSecondary, + style = HedvigTheme.typography.label, + ) + } } } @Composable @Preview -@HedvigPreview +@HedvigShortMultiScreenPreview private fun ManualChargeScreenSuccessPreview( @PreviewParameter( BooleanCollectionPreviewParameterProvider::class, @@ -317,13 +454,36 @@ private fun ManualChargeScreenSuccessPreview( HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ManualChargeScreen( - uiState = ManualChargeUiState.Success( + uiState = Success( ManualChargeInfo( missedDueDate = LocalDate(2026, 1, 15), amountDue = UiMoney(100.0, UiCurrencyCode.SEK), chargeId = "chargeId", - bankDescriptor = "**** 8324", - bankAccountDisplayValue = "Swedbank", + currentMethods = listOf( + SwishPayin( + "123456", + isPending = false, + isDefault = true, + ), + Trustly( + clearingNumber = "8327", + accountNumber = "91234124", + bankName = "Swedbank", + isPending = false, + isDefault = true, + ), + ), + availablePayinMethods = listOf( + MemberPaymentProvider.SWISH, + MemberPaymentProvider.TRUSTLY, + ), + primaryPayinMethod = Trustly( + clearingNumber = "8327", + accountNumber = "91234124", + bankName = "Swedbank", + isPending = false, + isDefault = true, + ), showCancellationWarning = showCancellationWarning, ), ), @@ -332,6 +492,8 @@ private fun ManualChargeScreenSuccessPreview( {}, {}, {}, + {}, + {}, ) } } @@ -344,12 +506,14 @@ private fun ManualChargeScreenLoadingPreview() { HedvigTheme { Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { ManualChargeScreen( - uiState = ManualChargeUiState.Loading, + uiState = Loading, navigateUp = {}, reload = {}, {}, {}, {}, + {}, + {}, ) } } @@ -366,7 +530,7 @@ private fun ManualChargeScreenFailurePreview( HedvigTheme { Surface { ManualChargeScreen( - uiState = ManualChargeUiState.Failure( + uiState = Failure( ErrorMessage( message = if (hasUserError) { "Cannot charge the failed payment since there have been some changes. " + @@ -381,6 +545,8 @@ private fun ManualChargeScreenFailurePreview( {}, {}, {}, + {}, + {}, ) } } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt index 0f9dd48dc5..6217d7f955 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/manualcharge/ManualChargeViewModel.kt @@ -12,6 +12,7 @@ import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel import com.hedvig.android.feature.payments.data.GetManualChargeInfoUseCase import com.hedvig.android.feature.payments.data.ManualChargeInfo +import com.hedvig.android.feature.payments.data.ManualChargeInfoResult import com.hedvig.android.feature.payments.data.TriggerManualChargeUseCase import com.hedvig.android.feature.payments.navigation.ManualChargeKey import com.hedvig.android.feature.payments.navigation.ManualChargeSuccessKey @@ -41,6 +42,7 @@ private class ManualChargePresenter( @Composable override fun MoleculePresenterScope.present(lastState: ManualChargeUiState): ManualChargeUiState { var dataLoadIteration by remember { mutableIntStateOf(0) } + var refreshIteration by remember { mutableIntStateOf(0) } var screenState by remember { mutableStateOf(lastState) } var triggerChargeIteration by remember { mutableIntStateOf(0) } @@ -53,6 +55,10 @@ private class ManualChargePresenter( ManualChargeEvent.TriggerCharge -> { triggerChargeIteration++ } + + ManualChargeEvent.Refresh -> { + refreshIteration++ + } } } @@ -79,16 +85,36 @@ private class ManualChargePresenter( LaunchedEffect(dataLoadIteration) { screenState = ManualChargeUiState.Loading getManualChargeInfoUseCase.invoke().fold( - ifRight = { manualChargeInfo -> - screenState = ManualChargeUiState.Success( - manualChargeInfo = manualChargeInfo, - ) + ifRight = { result -> + screenState = when (result) { + is ManualChargeInfoResult.Chargeable -> ManualChargeUiState.Success(result.info) + ManualChargeInfoResult.NoLongerChargeable -> ManualChargeUiState.NoLongerChargeable + } }, ifLeft = { failure -> screenState = ManualChargeUiState.Failure(failure) }, ) } + + // Picks up a payin method connected or made primary further down the flow. Only a failure to + // reach the backend is ignored, so coming back never flashes to loading or to an error over + // details the member can still act on. The backend withdrawing the charge is not ignored: those + // details are no longer payable, so leaving them on screen would offer a charge that cannot go + // through. + LaunchedEffect(refreshIteration) { + if (refreshIteration == 0) return@LaunchedEffect + getManualChargeInfoUseCase.invoke().onRight { result -> + screenState = when (result) { + is ManualChargeInfoResult.Chargeable -> ManualChargeUiState.Success( + manualChargeInfo = result.info, + payButtonLoading = (screenState as? ManualChargeUiState.Success)?.payButtonLoading == true, + ) + + ManualChargeInfoResult.NoLongerChargeable -> ManualChargeUiState.NoLongerChargeable + } + } + } return screenState } } @@ -96,6 +122,9 @@ private class ManualChargePresenter( internal sealed interface ManualChargeUiState { data object Loading : ManualChargeUiState + /** Nothing left for the member to settle here, so the screen offers a way to ask about it instead. */ + data object NoLongerChargeable : ManualChargeUiState + data class Failure( val error: ErrorMessage, ) : ManualChargeUiState @@ -109,5 +138,7 @@ internal sealed interface ManualChargeUiState { internal sealed interface ManualChargeEvent { data object Retry : ManualChargeEvent + data object Refresh : ManualChargeEvent + data object TriggerCharge : ManualChargeEvent } diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt deleted file mode 100644 index a7842273ea..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsDestination.kt +++ /dev/null @@ -1,385 +0,0 @@ -package com.hedvig.android.feature.payments.ui.memberpaymentdetails - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeDrawing -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.windowInsetsBottomHeight -import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.design.system.hedvig.ButtonDefaults -import com.hedvig.android.design.system.hedvig.ButtonDefaults.ButtonSize.Large -import com.hedvig.android.design.system.hedvig.HedvigBottomSheet -import com.hedvig.android.design.system.hedvig.HedvigButton -import com.hedvig.android.design.system.hedvig.HedvigErrorSection -import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgress -import com.hedvig.android.design.system.hedvig.HedvigNotificationCard -import com.hedvig.android.design.system.hedvig.HedvigScaffold -import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview -import com.hedvig.android.design.system.hedvig.HedvigText -import com.hedvig.android.design.system.hedvig.HedvigTextButton -import com.hedvig.android.design.system.hedvig.HedvigTheme -import com.hedvig.android.design.system.hedvig.HorizontalDivider -import com.hedvig.android.design.system.hedvig.HorizontalItemsWithMaximumSpaceTaken -import com.hedvig.android.design.system.hedvig.Icon -import com.hedvig.android.design.system.hedvig.IconButton -import com.hedvig.android.design.system.hedvig.NotificationDefaults -import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.api.HedvigBottomSheetState -import com.hedvig.android.design.system.hedvig.datepicker.getLocale -import com.hedvig.android.design.system.hedvig.icon.HedvigIcons -import com.hedvig.android.design.system.hedvig.icon.InfoFilled -import com.hedvig.android.design.system.hedvig.rememberHedvigBottomSheetState -import com.hedvig.android.feature.payments.data.MemberPaymentsDetails -import com.hedvig.android.feature.payments.data.PaymentAccount -import com.hedvig.android.feature.payments.data.PaymentMethod -import com.hedvig.android.placeholder.PlaceholderHighlight -import com.hedvig.android.placeholder.shimmer -import hedvig.resources.DASHBOARD_OPEN_CHAT -import hedvig.resources.KIVRA_NOTIFICATION_BOX_TEXT -import hedvig.resources.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON -import hedvig.resources.R -import hedvig.resources.Res -import hedvig.resources.swish -import kotlinx.datetime.LocalDate -import org.jetbrains.compose.resources.stringResource - -@Composable -internal fun MemberPaymentDetailsDestination( - viewModel: MemberPaymentDetailsViewModel, - navigateUp: () -> Unit, - onChangeBankAccount: () -> Unit, -) { - val uiState by viewModel.uiState.collectAsStateWithLifecycle() - MemberPaymentDetailsScreen( - uiState = uiState, - retry = { - viewModel.emit(MemberPaymentDetailsEvent.Retry) - }, - navigateUp = navigateUp, - onChangeBankAccount = onChangeBankAccount, - ) -} - -@Composable -private fun MemberPaymentDetailsScreen( - uiState: MemberPaymentDetailsUiState, - retry: () -> Unit, - navigateUp: () -> Unit, - onChangeBankAccount: () -> Unit, -) { - HedvigScaffold( - topAppBarText = stringResource(R.string.PAYMENTS_PAYMENT_DETAILS_INFO_TITLE), - navigateUp = navigateUp, - ) { - when (uiState) { - MemberPaymentDetailsUiState.Failure -> { - HedvigErrorSection( - modifier = Modifier.weight(1f), - onButtonClick = retry, - buttonText = stringResource(R.string.GENERAL_RETRY), - ) - } - - MemberPaymentDetailsUiState.Loading -> { - HedvigFullScreenCenterAlignedProgress(Modifier.weight(1f)) - } - - is MemberPaymentDetailsUiState.Success -> { - MemberPaymentDetailsSuccessScreen( - uiState, - onChangeBankAccount, - modifier = Modifier.weight(1f), - ) - } - } - } -} - -@Composable -private fun MemberPaymentDetailsSuccessScreen( - uiState: MemberPaymentDetailsUiState.Success, - onChangeBankAccount: () -> Unit, - modifier: Modifier = Modifier, -) { - val paymentDetails = uiState.paymentDetails - Column(modifier.padding(horizontal = 16.dp).verticalScroll(rememberScrollState())) { - val explanationBottomSheetState = rememberHedvigBottomSheetState() - ExplanationBottomSheet(explanationBottomSheetState) - HorizontalItemsWithMaximumSpaceTaken( - startSlot = { - HedvigText(stringResource(id = R.string.PAYMENTS_PAYMENT_METHOD)) - }, - endSlot = { - HedvigText( - text = paymentDetails.paymentMethod.label(), - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - color = HedvigTheme.colorScheme.textSecondary, - ) - }, - modifier = Modifier.padding(vertical = 16.dp), - spaceBetween = 8.dp, - ) - HorizontalDivider() - - val dayOfMonth = paymentDetails.chargingDayInTheMonth - if (dayOfMonth != null) { - HorizontalItemsWithMaximumSpaceTaken( - startSlot = { - HedvigText(stringResource(id = R.string.PAYMENTS_PAYMENT_DUE)) - }, - endSlot = { - val dayOfMonthFormatted = dayOfMonth.format() - Row(horizontalArrangement = Arrangement.End) { - HedvigText( - text = stringResource( - R.string.PAYMENTS_DUE_DESCRIPTION, - dayOfMonthFormatted, - ), - textAlign = TextAlign.End, - modifier = Modifier.weight(1f, false), - color = HedvigTheme.colorScheme.textSecondary, - ) - val explanation = paymentDetails.explanationData(dayOfMonthFormatted) - if (explanation != null) { - Spacer(Modifier.width(8.dp)) - IconButton( - onClick = { explanationBottomSheetState.show(explanation) }, - modifier = Modifier.size(24.dp), - ) { - Icon( - imageVector = HedvigIcons.InfoFilled, - contentDescription = stringResource(R.string.REFERRALS_INFO_BUTTON_CONTENT_DESCRIPTION), - modifier = Modifier.size(24.dp), - tint = HedvigTheme.colorScheme.fillSecondaryTransparent, - ) - } - } - } - }, - modifier = Modifier.padding(vertical = 16.dp), - spaceBetween = 8.dp, - ) - HorizontalDivider() - } - val account = paymentDetails.account - if (account is PaymentAccount.BankAccount) { - HorizontalItemsWithMaximumSpaceTaken( - startSlot = { HedvigText(stringResource(id = R.string.PAYMENTS_BANK_LABEL)) }, - endSlot = { - HedvigText( - text = account.bank, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - color = HedvigTheme.colorScheme.textSecondary, - ) - }, - spaceBetween = 8.dp, - modifier = Modifier.padding(vertical = 16.dp), - ) - HorizontalDivider() - } - val accountLabel = account?.label() - if (accountLabel != null) { - HorizontalItemsWithMaximumSpaceTaken( - startSlot = { - HedvigText(stringResource(id = R.string.PAYMENTS_ACCOUNT)) - }, - endSlot = { - HedvigText( - text = accountLabel, - textAlign = TextAlign.End, - modifier = Modifier.fillMaxWidth(), - color = HedvigTheme.colorScheme.textSecondary, - ) - }, - modifier = Modifier.padding(vertical = 16.dp), - spaceBetween = 8.dp, - ) - HorizontalDivider() - } - Spacer(Modifier.weight(1f)) - Spacer(Modifier.height(16.dp)) - when { - paymentDetails.paymentMethod == PaymentMethod.TRUSTLY -> { - HedvigButton( - text = stringResource(R.string.PROFILE_PAYMENT_CHANGE_BANK_ACCOUNT), - onClick = onChangeBankAccount, - enabled = true, - buttonStyle = ButtonDefaults.ButtonStyle.Secondary, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)), - ) - } - - paymentDetails.paymentMethod == PaymentMethod.INVOICE && account == PaymentAccount.Kivra -> { - HedvigNotificationCard( - message = stringResource(Res.string.KIVRA_NOTIFICATION_BOX_TEXT), - priority = NotificationDefaults.NotificationPriority.Info, - style = NotificationDefaults.InfoCardStyle.Button( - buttonText = stringResource(Res.string.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON), - onButtonClick = onChangeBankAccount, - ), - ) - } - } - - Spacer(Modifier.height(16.dp)) - } -} - -@Composable -private fun PaymentMethod.label(): String = when (this) { - PaymentMethod.TRUSTLY -> stringResource(R.string.PAYMENTS_AUTOGIRO_LABEL) - PaymentMethod.NORDEA -> stringResource(R.string.BANK_PAYOUT_METHOD_CARD_TITLE) - PaymentMethod.INVOICE -> stringResource(R.string.PAYMENTS_INVOICE) - PaymentMethod.SWISH -> stringResource(Res.string.swish) -} - -@Composable -private fun PaymentAccount.label(): String = when (this) { - PaymentAccount.Kivra -> "Kivra" - is PaymentAccount.Email -> email - is PaymentAccount.PhoneNumber -> phoneNumber - is PaymentAccount.BankAccount -> account -} - -private fun MemberPaymentsDetails.explanationData(dayOfMonthFormatted: String): PaymentExplanationData? = when { - paymentMethod == PaymentMethod.TRUSTLY -> PaymentExplanationData.Trustly(dayOfMonthFormatted) - paymentMethod == PaymentMethod.INVOICE && account == PaymentAccount.Kivra -> PaymentExplanationData.Kivra - else -> null -} - -private sealed interface PaymentExplanationData { - data object Kivra : PaymentExplanationData - - data class Trustly(val dueDate: String) : PaymentExplanationData -} - -@Composable -private fun ExplanationBottomSheet(sheetState: HedvigBottomSheetState) { - HedvigBottomSheet(sheetState) { data -> - val text = when (data) { - PaymentExplanationData.Kivra -> stringResource(R.string.KIVRA_PAYMENT_INFO) - is PaymentExplanationData.Trustly -> stringResource(R.string.PAYMENTS_PAYMENT_DUE_INFO, data.dueDate) - } - HedvigText( - text = text, - modifier = Modifier - .fillMaxWidth(), - ) - Spacer(Modifier.height(32.dp)) - HedvigTextButton( - text = stringResource(id = R.string.general_close_button), - buttonSize = Large, - onClick = { sheetState.dismiss() }, - modifier = Modifier.fillMaxWidth(), - ) - Spacer(Modifier.height(8.dp)) - Spacer(Modifier.windowInsetsBottomHeight(WindowInsets.safeDrawing)) - } -} - -@Composable -private fun Int.format(): String { - val day = this - val lastDigit = day % 10 - - val locale = getLocale() - - val suffix = when (locale.language) { - "en" -> when (lastDigit) { - 1 -> "st" - 2 -> "nd" - 3 -> "rd" - else -> "th" - } - - "sv" -> when (day) { - 11, 12 -> ":e" - - else -> when (lastDigit) { - 1, 2 -> ":a" - else -> ":e" - } - } - - else -> when (lastDigit) { - 1 -> "st" - 2 -> "nd" - 3 -> "rd" - else -> "th" - } - } - - return "$day$suffix" -} - -@Composable -@HedvigShortMultiScreenPreview -internal fun MemberPaymentDetailsScreenPreview( - @PreviewParameter( - MemberPaymentDetailsUiStatePreviewParameterProvider::class, - ) uiState: MemberPaymentDetailsUiState, -) { - HedvigTheme { - Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { - MemberPaymentDetailsScreen( - uiState, - {}, - {}, - {}, - ) - } - } -} - -private class MemberPaymentDetailsUiStatePreviewParameterProvider() : - CollectionPreviewParameterProvider( - listOf( - MemberPaymentDetailsUiState.Failure, - MemberPaymentDetailsUiState.Loading, - MemberPaymentDetailsUiState.Success( - paymentDetails = MemberPaymentsDetails( - paymentMethod = PaymentMethod.TRUSTLY, - chargingDayInTheMonth = 28, - account = PaymentAccount.BankAccount(account = "12345-67890", bank = "Nordea"), - ), - ), - MemberPaymentDetailsUiState.Success( - paymentDetails = MemberPaymentsDetails( - paymentMethod = PaymentMethod.INVOICE, - chargingDayInTheMonth = 28, - account = PaymentAccount.Kivra, - ), - ), - MemberPaymentDetailsUiState.Success( - paymentDetails = MemberPaymentsDetails( - paymentMethod = PaymentMethod.SWISH, - chargingDayInTheMonth = 28, - account = PaymentAccount.PhoneNumber("0701234567"), - ), - ), - ), - ) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsViewModel.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsViewModel.kt deleted file mode 100644 index 85cae92b55..0000000000 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/memberpaymentdetails/MemberPaymentDetailsViewModel.kt +++ /dev/null @@ -1,75 +0,0 @@ -package com.hedvig.android.feature.payments.ui.memberpaymentdetails - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableIntStateOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import com.hedvig.android.core.common.di.ActivityRetainedScope -import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.feature.payments.data.GetMemberPaymentsDetailsUseCase -import com.hedvig.android.feature.payments.data.MemberPaymentsDetails -import com.hedvig.android.molecule.public.MoleculePresenter -import com.hedvig.android.molecule.public.MoleculePresenterScope -import com.hedvig.android.molecule.public.MoleculeViewModel -import dev.zacsweers.metro.Inject - -@Inject -@HedvigViewModel(ActivityRetainedScope::class) -internal class MemberPaymentDetailsViewModel( - getMemberPaymentsDetailsUseCase: GetMemberPaymentsDetailsUseCase, -) : MoleculeViewModel( - initialState = MemberPaymentDetailsUiState.Loading, - presenter = MemberPaymentDetailsPresenter( - getMemberPaymentsDetailsUseCase, - ), - ) - -private class MemberPaymentDetailsPresenter( - private val getMemberPaymentsDetailsUseCase: GetMemberPaymentsDetailsUseCase, -) : MoleculePresenter { - @Composable - override fun MoleculePresenterScope.present( - lastState: MemberPaymentDetailsUiState, - ): MemberPaymentDetailsUiState { - var dataLoadIteration by remember { mutableIntStateOf(0) } - var currentState by remember { - mutableStateOf(lastState) - } - - CollectEvents { - when (it) { - MemberPaymentDetailsEvent.Retry -> dataLoadIteration++ - } - } - - LaunchedEffect(dataLoadIteration) { - currentState = MemberPaymentDetailsUiState.Loading - getMemberPaymentsDetailsUseCase.invoke().fold( - ifLeft = { - currentState = MemberPaymentDetailsUiState.Failure - }, - ifRight = { - currentState = MemberPaymentDetailsUiState.Success(it) - }, - ) - } - return currentState - } -} - -internal sealed interface MemberPaymentDetailsUiState { - data object Loading : MemberPaymentDetailsUiState - - data object Failure : MemberPaymentDetailsUiState - - data class Success( - val paymentDetails: MemberPaymentsDetails, - ) : MemberPaymentDetailsUiState -} - -internal sealed interface MemberPaymentDetailsEvent { - data object Retry : MemberPaymentDetailsEvent -} diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt index 5d674040e5..7649d572b7 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsDestination.kt @@ -1,8 +1,6 @@ package com.hedvig.android.feature.payments.ui.payments -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.expandVertically +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -16,6 +14,7 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing @@ -30,10 +29,8 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -64,7 +61,6 @@ import com.hedvig.android.design.system.hedvig.NotificationDefaults.InfoCardStyl import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Info import com.hedvig.android.design.system.hedvig.Surface -import com.hedvig.android.design.system.hedvig.debugBorder import com.hedvig.android.design.system.hedvig.hedvigDropShadow import com.hedvig.android.design.system.hedvig.icon.Campaign import com.hedvig.android.design.system.hedvig.icon.Card @@ -72,13 +68,18 @@ import com.hedvig.android.design.system.hedvig.icon.ChevronRight import com.hedvig.android.design.system.hedvig.icon.Clock import com.hedvig.android.design.system.hedvig.icon.HedvigIcons import com.hedvig.android.design.system.hedvig.icon.PaymentOutline +import com.hedvig.android.design.system.hedvig.icon.Trustly import com.hedvig.android.design.system.hedvig.icon.WarningFilled +import com.hedvig.android.design.system.hedvig.icon.colored.Kivra +import com.hedvig.android.design.system.hedvig.icon.colored.Swish import com.hedvig.android.design.system.hedvig.placeholder.hedvigPlaceholder import com.hedvig.android.design.system.hedvig.placeholder.shimmer import com.hedvig.android.design.system.hedvig.rememberHedvigDateTimeFormatter import com.hedvig.android.design.system.hedvig.rememberHedvigMonthDateTimeFormatter +import com.hedvig.android.feature.payin.account.navigation.PayinMethodId import com.hedvig.android.feature.payments.data.ManualChargeToPrompt import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge +import com.hedvig.android.feature.payments.data.PrimaryPayinMethod import com.hedvig.android.feature.payments.ui.payments.PaymentsEvent.Retry import com.hedvig.android.feature.payments.ui.payments.PaymentsUiState.Content import com.hedvig.android.feature.payments.ui.payments.PaymentsUiState.Content.ConnectedPaymentInfo @@ -96,18 +97,21 @@ import com.hedvig.android.pullrefresh.PullRefreshIndicator import com.hedvig.android.pullrefresh.pullRefresh import com.hedvig.android.pullrefresh.rememberPullRefreshState import hedvig.resources.MY_PAYMENT_UPDATING_MESSAGE +import hedvig.resources.PAYMENTS_BANK_LABEL import hedvig.resources.PAYMENTS_DISCOUNTS_SECTION_TITLE +import hedvig.resources.PAYMENTS_INVOICE import hedvig.resources.PAYMENTS_IN_PROGRESS import hedvig.resources.PAYMENTS_MISSED_PAYMENT import hedvig.resources.PAYMENTS_NO_PAYMENTS_IN_PROGRESS -import hedvig.resources.PAYMENTS_PAYMENT_DETAILS_INFO_TITLE import hedvig.resources.PAYMENTS_PAYMENT_HISTORY_BUTTON_LABEL +import hedvig.resources.PAYMENTS_PAYMENT_METHOD import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_AMOUNT_DUE import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_BODY import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_BUTTON import hedvig.resources.PAYMENTS_PAYMENT_OVERDUE_TITLE import hedvig.resources.PAYMENTS_PROCESSING_PAYMENT import hedvig.resources.PAYMENTS_UPCOMING_PAYMENT +import hedvig.resources.PAYMENT_METHODS_TITLE import hedvig.resources.PAYOUT_ADD_PAYOUT_METHOD import hedvig.resources.PAYOUT_MISSING_INFO import hedvig.resources.PAYOUT_PAGE_HEADING @@ -117,6 +121,7 @@ import hedvig.resources.Res import hedvig.resources.TAB_PAYMENTS_TITLE import hedvig.resources.info_card_missing_payment_body import hedvig.resources.info_card_missing_payment_missing_payments_body +import hedvig.resources.swish import kotlin.time.Clock.System import kotlin.time.Duration.Companion.days import kotlinx.datetime.LocalDate @@ -131,7 +136,7 @@ internal fun PaymentsDestination( onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPayoutAccountClicked: () -> Unit, - onMemberPaymentDetailsClicked: () -> Unit, + onPaymentMethodsClicked: () -> Unit, onChangeBankAccount: () -> Unit, onOpenManualCharge: () -> Unit, ) { @@ -144,7 +149,7 @@ internal fun PaymentsDestination( onPaymentHistoryClicked = onPaymentHistoryClicked, onPayoutAccountClicked = onPayoutAccountClicked, onRetry = { viewModel.emit(Retry) }, - onPaymentDetailsClicked = onMemberPaymentDetailsClicked, + onPaymentMethodsClicked = onPaymentMethodsClicked, onOpenManualCharge = onOpenManualCharge, ) } @@ -157,7 +162,7 @@ private fun PaymentsScreen( onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPayoutAccountClicked: () -> Unit, - onPaymentDetailsClicked: () -> Unit, + onPaymentMethodsClicked: () -> Unit, onOpenManualCharge: () -> Unit, onRetry: () -> Unit, ) { @@ -220,7 +225,7 @@ private fun PaymentsScreen( onDiscountClicked = onDiscountClicked, onPaymentHistoryClicked = onPaymentHistoryClicked, onPayoutAccountClicked = onPayoutAccountClicked, - onPaymentDetailsClicked = onPaymentDetailsClicked, + onPaymentMethodsClicked = onPaymentMethodsClicked, onOpenManualCharge = onOpenManualCharge, ) Spacer(Modifier.height(16.dp)) @@ -246,7 +251,7 @@ private fun PaymentsContent( onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPayoutAccountClicked: () -> Unit, - onPaymentDetailsClicked: () -> Unit, + onPaymentMethodsClicked: () -> Unit, onOpenManualCharge: () -> Unit, modifier: Modifier = Modifier, ) { @@ -347,17 +352,28 @@ private fun PaymentsContent( ConnectedPaymentInfo.Unknown, is ConnectedPaymentInfo.Active, - -> {} + -> { + } } } + val primaryPayinMethod = (uiState as? Content)?.primaryPayinMethod + if (primaryPayinMethod != null) { + PrimaryPayinMethodSection( + method = primaryPayinMethod, + onClick = { + onPaymentMethodsClicked() + }, + ) + Spacer(Modifier.height(8.dp)) + } + PaymentsListItems( uiState, onDiscountClicked = onDiscountClicked, onPaymentHistoryClicked = onPaymentHistoryClicked, onPayoutAccountClicked = onPayoutAccountClicked, - onPaymentDetailsClicked = onPaymentDetailsClicked, - showPayoutButton = (uiState as? Content)?.showPayoutButton == true, + onPaymentMethodsClicked = onPaymentMethodsClicked, ) } } @@ -423,14 +439,88 @@ private fun UpcomingPaymentInfoCard(upcomingPaymentInfo: UpcomingPaymentInfo?, m } } +@Composable +private fun PrimaryPayinMethodSection(method: PrimaryPayinMethod, onClick: () -> Unit) { + val sideSpacing = Modifier + .padding(horizontal = 16.dp) + .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)) + Column(Modifier.fillMaxWidth()) { + HedvigText( + text = stringResource(Res.string.PAYMENTS_PAYMENT_METHOD), + modifier = sideSpacing.padding(vertical = 8.dp), + ) + HedvigCard( + onClick = onClick, + shape = HedvigTheme.shapes.cornerLarge, + modifier = sideSpacing.fillMaxWidth(), + ) { + Row( + modifier = Modifier + .heightIn(min = 64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PayinMethodPillow(method.id) + Spacer(Modifier.width(2.dp)) + Column( + Modifier + .weight(1f) + .padding(vertical = 8.dp), + ) { + HedvigText( + text = when (method.id) { + PayinMethodId.Trustly -> stringResource(Res.string.PAYMENTS_BANK_LABEL) + PayinMethodId.Swish -> stringResource(Res.string.swish) + PayinMethodId.Invoice -> method.descriptor ?: stringResource(Res.string.PAYMENTS_INVOICE) + }, + ) + val subtitle = method.descriptor.takeIf { method.id != PayinMethodId.Invoice } + if (subtitle != null) { + HedvigText( + text = subtitle, + style = HedvigTheme.typography.finePrint, + color = HedvigTheme.colorScheme.textSecondary, + ) + } + } +// Icon( +// imageVector = HedvigIcons.ChevronRight, +// contentDescription = null, +// modifier = Modifier.size(24.dp), +// ) + } + } + } +} + +/** Mirrors the tile the payin-account feature draws; the two features cannot share a composable. */ +@Composable +private fun PayinMethodPillow(id: PayinMethodId) { + val onDarkTile = id == PayinMethodId.Trustly + Surface( + shape = HedvigTheme.shapes.cornerSmall, + color = if (onDarkTile) HedvigTheme.colorScheme.fillBlack else HedvigTheme.colorScheme.fillWhite, + contentColor = if (onDarkTile) HedvigTheme.colorScheme.fillWhite else HedvigTheme.colorScheme.fillBlack, + modifier = Modifier.size(40.dp), + ) { + Box(Modifier.size(40.dp), contentAlignment = Alignment.Center) { + when (id) { + PayinMethodId.Trustly -> Icon(HedvigIcons.Trustly, null, Modifier.size(28.dp)) + PayinMethodId.Swish -> Image(HedvigIcons.Swish, null, Modifier.size(28.dp)) + PayinMethodId.Invoice -> Image(HedvigIcons.Kivra, null, Modifier.size(28.dp)) + } + } + } +} + @Composable private fun PaymentsListItems( uiState: PaymentsUiState, onDiscountClicked: () -> Unit, onPaymentHistoryClicked: () -> Unit, onPayoutAccountClicked: () -> Unit, - onPaymentDetailsClicked: () -> Unit, - showPayoutButton: Boolean, + onPaymentMethodsClicked: () -> Unit, ) { val listItemsSideSpacingModifier = Modifier .padding(horizontal = 16.dp) @@ -490,57 +580,47 @@ private fun PaymentsListItems( HorizontalDivider(listItemsSideSpacingModifier) } - val showPaymentDetailsItem = when (uiState.memberType) { + val showPaymentMethodsItem = when (uiState.memberType) { MemberType.QASA_ONLY_MEMBER -> false MemberType.STANDARD_MEMBER, MemberType.STANDARD_TO_QASA_MEMBER, - -> uiState.connectedPaymentInfo is ConnectedPaymentInfo.Active - } - if (showPaymentDetailsItem) { - PaymentsListItem( - text = stringResource(Res.string.PAYMENTS_PAYMENT_DETAILS_INFO_TITLE), - icon = { - Icon( - imageVector = HedvigIcons.Card, - contentDescription = null, - modifier = Modifier.size(24.dp), - ) - }, - modifier = Modifier - .clickable(onClick = onPaymentDetailsClicked) - .then(listItemsSideSpacingModifier) - .padding(vertical = 16.dp) - .fillMaxWidth(), - ) - HorizontalDivider(modifier = listItemsSideSpacingModifier) - } - - val showPayoutItem = when (uiState.memberType) { - MemberType.QASA_ONLY_MEMBER, - MemberType.STANDARD_TO_QASA_MEMBER, -> true - - MemberType.STANDARD_MEMBER -> showPayoutButton } - if (showPayoutItem) { + if (showPaymentMethodsItem) { PaymentsListItem( - text = stringResource(Res.string.PAYOUT_PAGE_HEADING), + text = stringResource(Res.string.PAYMENT_METHODS_TITLE), icon = { Icon( - imageVector = HedvigIcons.PaymentOutline, + imageVector = HedvigIcons.Card, contentDescription = null, modifier = Modifier.size(24.dp), ) }, modifier = Modifier - .clickable(onClick = onPayoutAccountClicked) + .clickable(onClick = onPaymentMethodsClicked) .then(listItemsSideSpacingModifier) .padding(vertical = 16.dp) .fillMaxWidth(), ) HorizontalDivider(modifier = listItemsSideSpacingModifier) } + PaymentsListItem( + text = stringResource(Res.string.PAYOUT_PAGE_HEADING), + icon = { + Icon( + imageVector = HedvigIcons.PaymentOutline, + contentDescription = null, + modifier = Modifier.size(24.dp), + ) + }, + modifier = Modifier + .clickable(onClick = onPayoutAccountClicked) + .then(listItemsSideSpacingModifier) + .padding(vertical = 16.dp) + .fillMaxWidth(), + ) + HorizontalDivider(modifier = listItemsSideSpacingModifier) } } } @@ -789,14 +869,14 @@ private fun PreviewPaymentScreen( Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { PaymentsScreen( uiState = uiState, - { _ -> }, - {}, - {}, - {}, - {}, - {}, - {}, - {}, + onUpcomingPaymentClicked = {}, + onChangeBankAccount = {}, + onDiscountClicked = {}, + onPaymentHistoryClicked = {}, + onPayoutAccountClicked = {}, + onPaymentMethodsClicked = {}, + onOpenManualCharge = {}, + onRetry = {}, ) } } @@ -813,7 +893,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = listOf(OngoingCharge("id", LocalDate.fromEpochDays(401), UiMoney(200.0, SEK))), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = true, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -828,7 +908,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -845,7 +925,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( null, ), - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_TO_QASA_MEMBER, ), ) @@ -860,7 +940,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = InProgress, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -881,7 +961,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< ), ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -896,7 +976,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Pending, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -917,7 +997,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( null, ), - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -934,7 +1014,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( null, ), - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -955,7 +1035,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( dueDateToConnect = System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, ), - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -976,7 +1056,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayinSetup( System.now().plus(30.days).toLocalDateTime(TimeZone.UTC).date, ), - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) @@ -987,7 +1067,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayoutSetup, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_TO_QASA_MEMBER, ), ) @@ -998,7 +1078,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_TO_QASA_MEMBER, ), ) @@ -1009,7 +1089,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayoutSetup, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.QASA_ONLY_MEMBER, ), ) @@ -1020,7 +1100,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.Active, - showPayoutButton = false, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.QASA_ONLY_MEMBER, ), ) @@ -1035,7 +1115,7 @@ private class PaymentsStatePreviewProvider : CollectionPreviewParameterProvider< upcomingPaymentInfo = UpcomingPaymentInfo.NoInfo, ongoingCharges = emptyList(), connectedPaymentInfo = ConnectedPaymentInfo.NeedsPayoutSetup, - showPayoutButton = true, + primaryPayinMethod = PrimaryPayinMethod(PayinMethodId.Swish, "070-990 12 32"), memberType = MemberType.STANDARD_MEMBER, ), ) diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt index c37975999b..1efe6f648b 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsPresenter.kt @@ -20,21 +20,16 @@ import com.hedvig.android.feature.payments.data.PaymentConnection.Pending import com.hedvig.android.feature.payments.data.PaymentConnection.Unknown import com.hedvig.android.feature.payments.data.PaymentOverview import com.hedvig.android.feature.payments.data.PaymentOverview.OngoingCharge -import com.hedvig.android.feature.payments.overview.data.GetShouldShowPayoutUseCase +import com.hedvig.android.feature.payments.data.PrimaryPayinMethod import com.hedvig.android.feature.payments.overview.data.GetUpcomingPaymentUseCase import com.hedvig.android.feature.payments.ui.payments.PaymentsUiState.Content.ConnectedPaymentInfo import com.hedvig.android.molecule.public.MoleculePresenter import com.hedvig.android.molecule.public.MoleculePresenterScope -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.delay import kotlinx.datetime.LocalDate internal class PaymentsPresenter( private val getUpcomingPaymentUseCase: GetUpcomingPaymentUseCase, - getShouldShowPayoutUseCase: GetShouldShowPayoutUseCase, ) : MoleculePresenter { - private val shouldShowPayoutPresenter = ShouldShowPayoutPresenter(getShouldShowPayoutUseCase) - @Composable override fun MoleculePresenterScope.present(lastState: PaymentsUiState): PaymentsUiState { var loadIteration by remember { mutableIntStateOf(0) } @@ -51,8 +46,6 @@ internal class PaymentsPresenter( paymentOverviewResult = getUpcomingPaymentUseCase.invoke() } - val shouldShowPayout = shouldShowPayoutPresenter.present(loadIteration) - val currentPaymentResult = paymentOverviewResult ?: return PaymentsUiState.Loading return currentPaymentResult.fold( @@ -83,7 +76,7 @@ internal class PaymentsPresenter( }, ongoingCharges = paymentOverview.ongoingCharges, connectedPaymentInfo = paymentOverview.paymentConnection.toConnectedPaymentInfo(), - showPayoutButton = shouldShowPayout, + primaryPayinMethod = paymentOverview.primaryPayinMethod, memberType = paymentOverview.memberType, ) }, @@ -91,29 +84,6 @@ internal class PaymentsPresenter( } } -private class ShouldShowPayoutPresenter( - private val getShouldShowPayoutUseCase: GetShouldShowPayoutUseCase, -) { - @Composable - fun present(loadIteration: Int): Boolean { - var shouldShowPayout by remember { mutableStateOf(false) } - LaunchedEffect(loadIteration) { - shouldShowPayout = false - for (attempt in 0..2) { - delay(attempt.seconds) - getShouldShowPayoutUseCase.invoke().fold( - ifLeft = {}, - ifRight = { result -> - shouldShowPayout = result - return@LaunchedEffect - }, - ) - } - } - return shouldShowPayout - } -} - private fun PaymentConnection.toConnectedPaymentInfo(): ConnectedPaymentInfo { return when (this) { Active -> ConnectedPaymentInfo.Active @@ -145,7 +115,7 @@ internal sealed interface PaymentsUiState { val upcomingPaymentInfo: UpcomingPaymentInfo, val ongoingCharges: List, val connectedPaymentInfo: ConnectedPaymentInfo, - val showPayoutButton: Boolean, + val primaryPayinMethod: PrimaryPayinMethod?, val memberType: MemberType, ) : PaymentsUiState { sealed interface UpcomingPayment { diff --git a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsViewModel.kt b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsViewModel.kt index 21dc4d8064..0cd7996b1c 100644 --- a/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsViewModel.kt +++ b/app/feature/feature-payments/src/main/kotlin/com/hedvig/android/feature/payments/ui/payments/PaymentsViewModel.kt @@ -2,7 +2,6 @@ package com.hedvig.android.feature.payments.ui.payments import com.hedvig.android.core.common.di.ActivityRetainedScope import com.hedvig.android.core.common.di.HedvigViewModel -import com.hedvig.android.feature.payments.overview.data.GetShouldShowPayoutUseCase import com.hedvig.android.feature.payments.overview.data.GetUpcomingPaymentUseCase import com.hedvig.android.molecule.public.MoleculeViewModel import dev.zacsweers.metro.Inject @@ -11,11 +10,9 @@ import dev.zacsweers.metro.Inject @HedvigViewModel(ActivityRetainedScope::class) internal class PaymentsViewModel( getUpcomingPaymentUseCase: GetUpcomingPaymentUseCase, - getShouldShowPayoutUseCase: GetShouldShowPayoutUseCase, ) : MoleculeViewModel( PaymentsUiState.Loading, PaymentsPresenter( getUpcomingPaymentUseCase = getUpcomingPaymentUseCase, - getShouldShowPayoutUseCase = getShouldShowPayoutUseCase, ), ) diff --git a/app/feature/feature-payout-account/src/main/graphql/GetPayoutMethods.graphql b/app/feature/feature-payout-account/src/main/graphql/GetPayoutMethods.graphql index 944ae5d726..2525f7c348 100644 --- a/app/feature/feature-payout-account/src/main/graphql/GetPayoutMethods.graphql +++ b/app/feature/feature-payout-account/src/main/graphql/GetPayoutMethods.graphql @@ -1,6 +1,6 @@ query GetPayoutMethods { currentMember { - paymentMethods { + paymentMethods(version: 2) { payoutMethods { provider status diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/GetPayoutAccountUseCase.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/GetPayoutAccountUseCase.kt index a3b0224833..3c0ac18c8c 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/GetPayoutAccountUseCase.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/GetPayoutAccountUseCase.kt @@ -13,7 +13,6 @@ import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import octopus.GetPayoutMethodsQuery import octopus.GetPayoutMethodsQuery.Data.CurrentMember.PaymentMethods.PayoutMethod.Details.Companion.asPaymentMethodBankAccountDetails -import octopus.GetPayoutMethodsQuery.Data.CurrentMember.PaymentMethods.PayoutMethod.Details.Companion.asPaymentMethodInvoiceDetails import octopus.GetPayoutMethodsQuery.Data.CurrentMember.PaymentMethods.PayoutMethod.Details.Companion.asPaymentMethodSwishDetails import octopus.type.MemberPaymentMethodStatus import octopus.type.MemberPaymentProvider @@ -46,15 +45,6 @@ internal class GetPayoutAccountUseCase( PayoutAccount.SwishPayout(phoneNumber = phoneNumber, isPending = isPending) } - MemberPaymentProvider.INVOICE -> { - val invoiceDetails = method.details?.asPaymentMethodInvoiceDetails() - PayoutAccount.Invoice( - delivery = invoiceDetails?.delivery, - email = invoiceDetails?.email, - isPending = isPending, - ) - } - MemberPaymentProvider.TRUSTLY -> { val (clearingNumber, accountNumber, bankName) = parseBankAccountDetails(method) PayoutAccount.Trustly( diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/PayoutAccount.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/PayoutAccount.kt index 8a7b7b3bdc..88cd9cb582 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/PayoutAccount.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/data/PayoutAccount.kt @@ -1,7 +1,5 @@ package com.hedvig.android.feature.payoutaccount.data -import octopus.type.PaymentMethodInvoiceDelivery - internal sealed interface PayoutAccount { val isPending: Boolean @@ -23,10 +21,4 @@ internal sealed interface PayoutAccount { val bankName: String?, override val isPending: Boolean, ) : PayoutAccount - - data class Invoice( - val delivery: PaymentMethodInvoiceDelivery?, - val email: String?, - override val isPending: Boolean, - ) : PayoutAccount } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountDestination.kt index ab443beb93..81b79fa70e 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountDestination.kt @@ -16,6 +16,3 @@ internal data object EditBankAccountKey : HedvigNavKey @Serializable internal data object SetupSwishPayoutKey : HedvigNavKey - -@Serializable -internal data object SetupInvoicePayoutKey : HedvigNavKey diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt index 2f6947d539..375bfd4cb8 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/navigation/PayoutAccountEntries.kt @@ -2,15 +2,12 @@ package com.hedvig.android.feature.payoutaccount.navigation import androidx.lifecycle.compose.dropUnlessResumed import androidx.navigation3.runtime.EntryProviderScope -import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.feature.payoutaccount.ui.editbankaccount.EditBankAccountDestination import com.hedvig.android.feature.payoutaccount.ui.editbankaccount.EditBankAccountViewModel import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewDestination import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewUiState import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewViewModel import com.hedvig.android.feature.payoutaccount.ui.selectmethod.SelectPayoutMethodDestination -import com.hedvig.android.feature.payoutaccount.ui.setupinvoice.SetupInvoicePayoutDestination -import com.hedvig.android.feature.payoutaccount.ui.setupinvoice.SetupInvoicePayoutViewModel import com.hedvig.android.feature.payoutaccount.ui.setupswish.SetupSwishPayoutDestination import com.hedvig.android.feature.payoutaccount.ui.setupswish.SetupSwishPayoutViewModel import com.hedvig.android.navigation.common.HedvigNavKey @@ -20,11 +17,7 @@ import com.hedvig.android.navigation.compose.popUpTo import dev.zacsweers.metrox.viewmodel.metroViewModel import octopus.type.MemberPaymentProvider -fun EntryProviderScope.payoutAccountEntries( - backstack: Backstack, - globalSnackBarState: GlobalSnackBarState, - navigateToConnectPayment: () -> Unit, -) { +fun EntryProviderScope.payoutAccountEntries(backstack: Backstack, navigateToConnectPayment: () -> Unit) { entry { val viewModel: PayoutAccountOverviewViewModel = metroViewModel() PayoutAccountOverviewDestination( @@ -54,7 +47,6 @@ fun EntryProviderScope.payoutAccountEntries( }, onNordeaSelected = dropUnlessResumed { backstack.add(EditBankAccountKey) }, onSwishSelected = dropUnlessResumed { backstack.add(SetupSwishPayoutKey) }, - onInvoiceSelected = dropUnlessResumed { backstack.add(SetupInvoicePayoutKey) }, navigateUp = backstack::navigateUp, ) } @@ -63,7 +55,6 @@ fun EntryProviderScope.payoutAccountEntries( val viewModel: EditBankAccountViewModel = metroViewModel() EditBankAccountDestination( viewModel = viewModel, - globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, ) } @@ -72,16 +63,6 @@ fun EntryProviderScope.payoutAccountEntries( val viewModel: SetupSwishPayoutViewModel = metroViewModel() SetupSwishPayoutDestination( viewModel = viewModel, - globalSnackBarState = globalSnackBarState, - navigateUp = backstack::navigateUp, - ) - } - - entry { - val viewModel: SetupInvoicePayoutViewModel = metroViewModel() - SetupInvoicePayoutDestination( - viewModel = viewModel, - globalSnackBarState = globalSnackBarState, navigateUp = backstack::navigateUp, ) } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodHandoverIllustration.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodHandoverIllustration.kt new file mode 100644 index 0000000000..aeebd0d5c9 --- /dev/null +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodHandoverIllustration.kt @@ -0,0 +1,60 @@ +package com.hedvig.android.feature.payoutaccount.ui.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.PaymentMethodHandoverIllustration +import com.hedvig.android.design.system.hedvig.PaymentMethodMarkSize +import com.hedvig.android.design.system.hedvig.Surface +import octopus.type.MemberPaymentProvider + +/** + * @param destinationBadge marks where the setup has got to once it has an outcome to show; absent + * while the member is still choosing. + */ +@Composable +internal fun PayoutMethodHandoverIllustration( + provider: MemberPaymentProvider?, + modifier: Modifier = Modifier, + destinationBadge: @Composable (() -> Unit)? = null, +) { + Column(modifier) { + Spacer(Modifier.height(48.dp)) + PaymentMethodHandoverIllustration( + destinationBadge = destinationBadge, + mark = { PayoutProviderMark(provider, Modifier.size(PaymentMethodMarkSize)) }, + ) + Spacer(Modifier.height(48.dp)) + } +} + +@Composable +@HedvigPreview +private fun PreviewPayoutMethodHandoverIllustration( + @PreviewParameter(PayoutPreviewProvider::class) provider: MemberPaymentProvider?, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + PayoutMethodHandoverIllustration(provider, Modifier.padding(16.dp)) + } + } +} + +private class PayoutPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + null, + MemberPaymentProvider.TRUSTLY, + MemberPaymentProvider.SWISH, + MemberPaymentProvider.NORDEA, + ), + ) diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodRow.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodRow.kt new file mode 100644 index 0000000000..6c83d0e316 --- /dev/null +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutMethodRow.kt @@ -0,0 +1,168 @@ +package com.hedvig.android.feature.payoutaccount.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.datasource.CollectionPreviewParameterProvider +import androidx.compose.ui.unit.dp +import com.hedvig.android.compose.ui.EmptyContentDescription +import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigPreview +import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.Icon +import com.hedvig.android.design.system.hedvig.PaymentMethodPillow +import com.hedvig.android.design.system.hedvig.PaymentMethodPillowMarkSize +import com.hedvig.android.design.system.hedvig.PaymentMethodPlusMark +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.icon.Card +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import com.hedvig.android.design.system.hedvig.icon.Lock +import com.hedvig.android.design.system.hedvig.icon.Trustly +import com.hedvig.android.design.system.hedvig.icon.colored.Swish +import octopus.type.MemberPaymentProvider + +/** How far the brand mark fades on a locked row, where it reads as a label rather than an action. */ +private const val LockedMarkAlpha = 0.5f + +/** + * The connected payout method as the overview shows it: a lock instead of a chevron, because the + * method is changed from the flow behind "change payout method" rather than by tapping the row. + */ +@Composable +internal fun LockedPayoutMethodRow( + provider: MemberPaymentProvider, + title: String, + subtitle: String, + modifier: Modifier = Modifier, +) { + HedvigCard(modifier = modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .heightIn(min = 64.dp) + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + LockedPayoutProviderPillow(provider) + Spacer(Modifier.width(10.dp)) + Column(Modifier.padding(vertical = 8.dp)) { + HedvigText( + text = title, + color = HedvigTheme.colorScheme.textSecondaryTranslucent, + ) + HedvigText( + text = subtitle, + color = HedvigTheme.colorScheme.textDisabledTranslucent, + ) + } + Spacer(Modifier.weight(1f)) + Spacer(Modifier.width(4.dp)) + Icon( + HedvigIcons.Lock, + EmptyContentDescription, + tint = HedvigTheme.colorScheme.fillDisabledTransparent, + // todo: get icon from design! + modifier = Modifier.size(28.dp), + ) + } + } +} + +/** + * The provider's brand mark on the tile it is drawn on: white behind the full-colour marks, black + * behind the monochrome ones, which pick up the tile's content colour. + */ +@Composable +internal fun PayoutProviderPillow(provider: MemberPaymentProvider?, modifier: Modifier = Modifier) { + val onDarkTile = provider.onDarkTile + PaymentMethodPillow( + modifier = modifier, + containerColor = if (onDarkTile) HedvigTheme.colorScheme.fillBlack else HedvigTheme.colorScheme.fillWhite, + contentColor = if (onDarkTile) HedvigTheme.colorScheme.fillWhite else HedvigTheme.colorScheme.fillBlack, + mark = { PayoutProviderMark(provider, Modifier.size(PaymentMethodPillowMarkSize)) }, + ) +} + +@Composable +private fun LockedPayoutProviderPillow(provider: MemberPaymentProvider, modifier: Modifier = Modifier) { + val onDarkTile = provider.onDarkTile + PaymentMethodPillow( + modifier = modifier, + containerColor = if (onDarkTile) HedvigTheme.colorScheme.fillDisabled else HedvigTheme.colorScheme.fillWhite, + contentColor = if (onDarkTile) HedvigTheme.colorScheme.fillWhite else HedvigTheme.colorScheme.fillBlack, + mark = { + PayoutProviderMark( + provider, + Modifier + .size(PaymentMethodPillowMarkSize) + .alpha(LockedMarkAlpha), + ) + }, + ) +} + +/** + * The provider's brand mark. The monochrome ones pick up the surrounding content colour; the + * full-colour ones ignore it. A provider with no mark of its own falls back to the plus, so one the + * backend adds later still renders. + */ +@Composable +internal fun PayoutProviderMark(provider: MemberPaymentProvider?, modifier: Modifier = Modifier) { + when (provider) { + MemberPaymentProvider.TRUSTLY -> Icon(HedvigIcons.Trustly, EmptyContentDescription, modifier) + + // todo: get icon from design! + MemberPaymentProvider.NORDEA -> Icon(HedvigIcons.Card, EmptyContentDescription, modifier) + + MemberPaymentProvider.SWISH -> Image(HedvigIcons.Swish, EmptyContentDescription, modifier) + + else -> PaymentMethodPlusMark(modifier) + } +} + +/** Trustly's mark is monochrome, so it reads better inverted on a dark tile than on the white one. */ +private val MemberPaymentProvider?.onDarkTile: Boolean + get() = this == MemberPaymentProvider.TRUSTLY + +@Composable +@HedvigPreview +private fun PreviewLockedPayoutMethodRow( + @PreviewParameter(PayoutProviderPreviewProvider::class) provider: MemberPaymentProvider, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + Column( + Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + LockedPayoutMethodRow( + provider = provider, + title = "Payout method", + subtitle = "123456789", + ) + PayoutProviderPillow(provider) + } + } + } +} + +private class PayoutProviderPreviewProvider : + CollectionPreviewParameterProvider( + listOf( + MemberPaymentProvider.SWISH, + MemberPaymentProvider.TRUSTLY, + MemberPaymentProvider.NORDEA, + ), + ) diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutSetupSuccessContent.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutSetupSuccessContent.kt new file mode 100644 index 0000000000..effc77956f --- /dev/null +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/components/PayoutSetupSuccessContent.kt @@ -0,0 +1,62 @@ +package com.hedvig.android.feature.payoutaccount.ui.components + +import androidx.compose.foundation.layout.ColumnScope +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.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hedvig.android.design.system.hedvig.HedvigButton +import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.PaymentMethodTileBadge +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.design.system.hedvig.icon.Checkmark +import com.hedvig.android.design.system.hedvig.icon.HedvigIcons +import hedvig.resources.Res +import hedvig.resources.general_continue_button +import octopus.type.MemberPaymentProvider +import org.jetbrains.compose.resources.stringResource + +/** + * How every payout setup ends: the method has landed, so the handover illustration is badged with a + * checkmark and the only thing left to do is leave the flow. + */ +@Composable +internal fun ColumnScope.PayoutSetupSuccessContent( + provider: MemberPaymentProvider, + title: String, + onContinue: () -> Unit, +) { + Spacer(Modifier.height(8.dp)) + FlowHeading( + title = title, + description = null, + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PayoutMethodHandoverIllustration( + provider, + modifier = Modifier.align(Alignment.CenterHorizontally), + destinationBadge = { + PaymentMethodTileBadge( + icon = HedvigIcons.Checkmark, + containerColor = HedvigTheme.colorScheme.signalGreenElement, + contentColor = HedvigTheme.colorScheme.fillWhite, + ) + }, + ) + Spacer(Modifier.weight(1f)) + HedvigButton( + text = stringResource(Res.string.general_continue_button), + onClick = onContinue, + enabled = true, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) +} diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt index ec456c6384..dc38788ef8 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountDestination.kt @@ -1,50 +1,48 @@ package com.hedvig.android.feature.payoutaccount.ui.editbankaccount -import android.R.attr.priority import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigNotificationCard import com.hedvig.android.design.system.hedvig.HedvigScaffold -import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.payoutaccount.ui.components.PayoutSetupSuccessContent import hedvig.resources.BANK_PAYOUT_METHOD_CARD_TITLE import hedvig.resources.PAYMENTS_ACCOUNT import hedvig.resources.Res import hedvig.resources.general_save_button import hedvig.resources.something_went_wrong +import octopus.type.MemberPaymentProvider import org.jetbrains.compose.resources.stringResource @Composable -internal fun EditBankAccountDestination( - viewModel: EditBankAccountViewModel, - globalSnackBarState: GlobalSnackBarState, - navigateUp: () -> Unit, -) { +internal fun EditBankAccountDestination(viewModel: EditBankAccountViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() EditBankAccountScreen( uiState = uiState, - globalSnackBarState = globalSnackBarState, onSave = { viewModel.emit(EditBankAccountEvent.Save) }, - showedSnackBar = { viewModel.emit(EditBankAccountEvent.ShowedSnackBar) }, + onFinishSetup = { viewModel.emit(EditBankAccountEvent.FinishSetup) }, navigateUp = navigateUp, ) } @@ -52,63 +50,91 @@ internal fun EditBankAccountDestination( @Composable private fun EditBankAccountScreen( uiState: EditBankAccountUiState, - globalSnackBarState: GlobalSnackBarState, onSave: () -> Unit, - showedSnackBar: () -> Unit, + onFinishSetup: () -> Unit, navigateUp: () -> Unit, ) { - LaunchedEffect(uiState.showSuccessSnackBar) { - if (!uiState.showSuccessSnackBar) return@LaunchedEffect - globalSnackBarState.show("Changes saved", NotificationPriority.Campaign) - showedSnackBar() - } - HedvigScaffold( topAppBarText = stringResource(Res.string.BANK_PAYOUT_METHOD_CARD_TITLE), navigateUp = navigateUp, modifier = Modifier.fillMaxSize(), ) { - Spacer(Modifier.weight(1f)) - Column(Modifier.padding(horizontal = 16.dp)) { - HedvigTextField( - state = uiState.accountNumberState, - labelText = buildString { - append(stringResource(Res.string.PAYMENTS_ACCOUNT)) - if (uiState.bankName != null) { - append(" - ") - append(uiState.bankName) - } - }, - textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, - inputTransformation = uiState.accountNumberInputTransformation, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.fillMaxWidth(), - ) - } - AnimatedVisibility( - visible = uiState.errorMessage != null, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - HedvigNotificationCard( - message = uiState.errorMessage?.ifBlank { stringResource(Res.string.something_went_wrong) }.orEmpty(), - priority = NotificationPriority.Attention, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp) - .fillMaxWidth(), + if (uiState.isConnected) { + PayoutSetupSuccessContent( + provider = MemberPaymentProvider.NORDEA, + // TODO: Add "Bank account is connected" / "Bankkontot är anslutet" to Lokalise + title = "Bank account is connected", + onContinue = onFinishSetup, ) + } else { + AccountNumberContent(uiState = uiState, onSave = onSave) } - Spacer(Modifier.height(16.dp)) - HedvigButton( - text = stringResource(Res.string.general_save_button), - onClick = onSave, - enabled = uiState.canSave, - isLoading = uiState.isLoading, + } +} + +@Composable +private fun ColumnScope.AccountNumberContent(uiState: EditBankAccountUiState, onSave: () -> Unit) { + Spacer(Modifier.weight(1f)) + Column(Modifier.padding(horizontal = 16.dp)) { + HedvigTextField( + state = uiState.accountNumberState, + labelText = buildString { + append(stringResource(Res.string.PAYMENTS_ACCOUNT)) + if (uiState.bankName != null) { + append(" - ") + append(uiState.bankName) + } + }, + textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + inputTransformation = uiState.accountNumberInputTransformation, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth(), + ) + } + AnimatedVisibility( + visible = uiState.errorMessage != null, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + HedvigNotificationCard( + message = uiState.errorMessage?.ifBlank { stringResource(Res.string.something_went_wrong) }.orEmpty(), + priority = NotificationPriority.Attention, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .padding(top = 4.dp) + .fillMaxWidth(), ) - Spacer(Modifier.height(16.dp)) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.general_save_button), + onClick = onSave, + enabled = uiState.canSave, + isLoading = uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewEditBankAccountScreenConnected() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + EditBankAccountScreen( + uiState = EditBankAccountUiState( + accountNumberState = TextFieldState(), + bankName = "Swedbank", + isLoading = false, + errorMessage = null, + isConnected = true, + ), + onSave = {}, + onFinishSetup = {}, + navigateUp = {}, + ) + } } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt index ed997c4cdc..b9fb69463b 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/editbankaccount/EditBankAccountViewModel.kt @@ -37,7 +37,7 @@ internal class EditBankAccountViewModel( bankName = null, isLoading = false, errorMessage = null, - showSuccessSnackBar = false, + isConnected = false, ), EditBankAccountPresenter(setupNordeaPayoutUseCase, backstack), ) @@ -45,7 +45,7 @@ internal class EditBankAccountViewModel( internal sealed interface EditBankAccountEvent { data object Save : EditBankAccountEvent - data object ShowedSnackBar : EditBankAccountEvent + data object FinishSetup : EditBankAccountEvent } internal data class EditBankAccountUiState( @@ -53,7 +53,7 @@ internal data class EditBankAccountUiState( val bankName: String?, val isLoading: Boolean, val errorMessage: String?, - val showSuccessSnackBar: Boolean, + val isConnected: Boolean, ) { val canSave: Boolean get() = !isLoading && accountNumberState.text.length in 10..17 @@ -74,7 +74,7 @@ internal class EditBankAccountPresenter( val bankName = bankNameForClearingNumber(accountNumberState.text.toString().take(4)) var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } - var showSuccessSnackBar by remember { mutableStateOf(false) } + var isConnected by remember { mutableStateOf(false) } var saveIteration by remember { mutableStateOf(null) } val currentSave = saveIteration @@ -90,7 +90,7 @@ internal class EditBankAccountPresenter( }, ifRight = { isLoading = false - showSuccessSnackBar = true + isConnected = true saveIteration = null }, ) @@ -111,7 +111,7 @@ internal class EditBankAccountPresenter( } } - EditBankAccountEvent.ShowedSnackBar -> { + EditBankAccountEvent.FinishSetup -> { backstack.popUpTo(inclusive = true) } } @@ -122,7 +122,7 @@ internal class EditBankAccountPresenter( bankName = bankName, isLoading = isLoading, errorMessage = errorMessage, - showSuccessSnackBar = showSuccessSnackBar, + isConnected = isConnected, ) } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/overview/PayoutAccountOverviewDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/overview/PayoutAccountOverviewDestination.kt index 859fd97808..f00f31e10b 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/overview/PayoutAccountOverviewDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/overview/PayoutAccountOverviewDestination.kt @@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hedvig.android.design.system.hedvig.EmptyState import com.hedvig.android.design.system.hedvig.EmptyStateDefaults +import com.hedvig.android.design.system.hedvig.EmptyStateDefaults.EmptyStateIconStyle import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigErrorSection import com.hedvig.android.design.system.hedvig.HedvigFullScreenCenterAlignedProgressDebounced @@ -27,15 +28,24 @@ import com.hedvig.android.design.system.hedvig.HedvigPreview import com.hedvig.android.design.system.hedvig.HedvigScaffold import com.hedvig.android.design.system.hedvig.HedvigTextField import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults +import com.hedvig.android.design.system.hedvig.HedvigTextFieldDefaults.TextFieldSize import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority.Info import com.hedvig.android.design.system.hedvig.Surface import com.hedvig.android.feature.payoutaccount.data.PayoutAccount +import com.hedvig.android.feature.payoutaccount.data.PayoutAccount.BankAccount +import com.hedvig.android.feature.payoutaccount.data.PayoutAccount.SwishPayout +import com.hedvig.android.feature.payoutaccount.data.PayoutAccount.Trustly +import com.hedvig.android.feature.payoutaccount.ui.components.LockedPayoutMethodRow +import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewEvent.Retry import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewUiState.Content +import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewUiState.Error +import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewUiState.Loading +import com.hedvig.android.feature.payoutaccount.ui.overview.PayoutAccountOverviewUiState.NoPayoutOptions import hedvig.resources.CHANGE_PAYOUT_METHOD_BUTTON_LABEL import hedvig.resources.MY_PAYMENT_UPDATING_MESSAGE import hedvig.resources.PAYMENTS_ACCOUNT -import hedvig.resources.PAYMENTS_INVOICE import hedvig.resources.PAYOUT_MISSING_INFO import hedvig.resources.PAYOUT_NO_PAYOUT_OPTIONS_SUBTITLE import hedvig.resources.PAYOUT_NO_PAYOUT_OPTIONS_TITLE @@ -44,10 +54,10 @@ import hedvig.resources.PAYOUT_SELECT_PAYOUT_METHOD import hedvig.resources.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON import hedvig.resources.REFERRAL_PENDING_STATUS_LABEL import hedvig.resources.Res +import hedvig.resources.Res.string import hedvig.resources.swish import hedvig.resources.trustly import octopus.type.MemberPaymentProvider -import octopus.type.PaymentMethodInvoiceDelivery import org.jetbrains.compose.resources.stringResource @Composable @@ -62,7 +72,7 @@ internal fun PayoutAccountOverviewDestination( uiState = uiState, onConnectPayoutMethodClicked = onConnectPayoutMethodClicked, navigateToConnectPayment = navigateToConnectPayment, - onRetry = { viewModel.emit(PayoutAccountOverviewEvent.Retry) }, + onRetry = { viewModel.emit(Retry) }, navigateUp = navigateUp, ) } @@ -76,12 +86,12 @@ private fun PayoutAccountOverviewScreen( navigateUp: () -> Unit, ) { HedvigScaffold( - topAppBarText = stringResource(Res.string.PAYOUT_PAGE_HEADING), + topAppBarText = stringResource(string.PAYOUT_PAGE_HEADING), navigateUp = navigateUp, modifier = Modifier.fillMaxSize(), ) { when (uiState) { - PayoutAccountOverviewUiState.Loading -> { + Loading -> { HedvigFullScreenCenterAlignedProgressDebounced( Modifier .weight(1f) @@ -89,7 +99,7 @@ private fun PayoutAccountOverviewScreen( ) } - PayoutAccountOverviewUiState.Error -> { + Error -> { HedvigErrorSection( onButtonClick = onRetry, modifier = Modifier @@ -98,11 +108,11 @@ private fun PayoutAccountOverviewScreen( ) } - PayoutAccountOverviewUiState.NoPayoutOptions -> { + NoPayoutOptions -> { HedvigInformationSection( - title = stringResource(Res.string.PAYOUT_NO_PAYOUT_OPTIONS_TITLE), - subTitle = stringResource(Res.string.PAYOUT_NO_PAYOUT_OPTIONS_SUBTITLE), - buttonText = stringResource(Res.string.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON), + title = stringResource(string.PAYOUT_NO_PAYOUT_OPTIONS_TITLE), + subTitle = stringResource(string.PAYOUT_NO_PAYOUT_OPTIONS_SUBTITLE), + buttonText = stringResource(string.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON), onButtonClick = navigateToConnectPayment, modifier = Modifier .weight(1f) @@ -136,53 +146,52 @@ private fun PayoutAccountContent( if (availablePayoutMethods.isNotEmpty()) { Spacer(Modifier.weight(1f)) EmptyState( - text = stringResource(Res.string.PAYOUT_MISSING_INFO), + text = stringResource(string.PAYOUT_MISSING_INFO), description = null, - iconStyle = EmptyStateDefaults.EmptyStateIconStyle.INFO, + iconStyle = EmptyStateIconStyle.INFO, ) } } - is PayoutAccount.SwishPayout -> { + is SwishPayout -> { val phoneNumber = currentMethod.phoneNumber.orEmpty() - PayoutAccountReadOnlyTextField( - label = stringResource(Res.string.swish), - text = if (currentMethod.isPending && phoneNumber.isBlank()) { - stringResource(Res.string.REFERRAL_PENDING_STATUS_LABEL) + LockedPayoutMethodRow( + provider = MemberPaymentProvider.SWISH, + title = stringResource(string.swish), + subtitle = if (currentMethod.isPending && phoneNumber.isBlank()) { + stringResource(string.REFERRAL_PENDING_STATUS_LABEL) } else { phoneNumber }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), ) } - is PayoutAccount.Trustly -> { + is Trustly -> { val accountNumber = formatBankAccountNumber(currentMethod.clearingNumber, currentMethod.accountNumber) - PayoutAccountReadOnlyTextField( - label = formatBankAccountLabel(stringResource(Res.string.trustly), currentMethod.bankName), - text = if (currentMethod.isPending && accountNumber.isBlank()) { - stringResource(Res.string.REFERRAL_PENDING_STATUS_LABEL) + LockedPayoutMethodRow( + title = formatBankAccountLabel(stringResource(string.trustly), currentMethod.bankName), + subtitle = if (currentMethod.isPending && accountNumber.isBlank()) { + stringResource(string.REFERRAL_PENDING_STATUS_LABEL) } else { accountNumber }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + provider = MemberPaymentProvider.TRUSTLY, ) } - is PayoutAccount.Invoice -> { - PayoutAccountReadOnlyTextField( - stringResource(Res.string.PAYMENTS_ACCOUNT), - stringResource(Res.string.PAYMENTS_INVOICE), - ) - } - - is PayoutAccount.BankAccount -> { + is BankAccount -> { val accountNumber = formatBankAccountNumber(currentMethod.clearingNumber, currentMethod.accountNumber) - PayoutAccountReadOnlyTextField( - label = formatBankAccountLabel(stringResource(Res.string.PAYMENTS_ACCOUNT), currentMethod.bankName), - text = if (currentMethod.isPending && accountNumber.isBlank()) { - stringResource(Res.string.REFERRAL_PENDING_STATUS_LABEL) + LockedPayoutMethodRow( + title = formatBankAccountLabel(stringResource(string.PAYMENTS_ACCOUNT), currentMethod.bankName), + subtitle = if (currentMethod.isPending && accountNumber.isBlank()) { + stringResource(string.REFERRAL_PENDING_STATUS_LABEL) } else { accountNumber }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp), + provider = MemberPaymentProvider.NORDEA, ) } } @@ -190,8 +199,8 @@ private fun PayoutAccountContent( Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { if (currentMethod?.isPending == true) { HedvigNotificationCard( - message = stringResource(Res.string.MY_PAYMENT_UPDATING_MESSAGE), - priority = NotificationPriority.Info, + message = stringResource(string.MY_PAYMENT_UPDATING_MESSAGE), + priority = Info, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp), @@ -200,9 +209,9 @@ private fun PayoutAccountContent( if (availablePayoutMethods.isNotEmpty()) { HedvigButton( text = if (currentMethod == null) { - stringResource(Res.string.PAYOUT_SELECT_PAYOUT_METHOD) + stringResource(string.PAYOUT_SELECT_PAYOUT_METHOD) } else { - stringResource(Res.string.CHANGE_PAYOUT_METHOD_BUTTON_LABEL) + stringResource(string.CHANGE_PAYOUT_METHOD_BUTTON_LABEL) }, onClick = onConnectPayoutMethodClicked, enabled = true, @@ -222,7 +231,7 @@ private fun PayoutAccountReadOnlyTextField(label: String, text: String, modifier text = text, onValueChange = {}, labelText = label, - textFieldSize = HedvigTextFieldDefaults.TextFieldSize.Medium, + textFieldSize = TextFieldSize.Medium, readOnly = true, modifier = modifier .fillMaxWidth() @@ -261,31 +270,31 @@ private fun PreviewPayoutAccountOverviewScreen( private class PayoutAccountOverviewUiStateProvider : CollectionPreviewParameterProvider( listOf( - PayoutAccountOverviewUiState.Loading, - PayoutAccountOverviewUiState.Error, - PayoutAccountOverviewUiState.NoPayoutOptions, + Loading, + Error, + NoPayoutOptions, Content( currentMethod = null, availablePayoutMethods = listOf(MemberPaymentProvider.SWISH, MemberPaymentProvider.TRUSTLY), ), Content( - currentMethod = PayoutAccount.SwishPayout(phoneNumber = "070-123 45 67", isPending = false), + currentMethod = SwishPayout(phoneNumber = "070-123 45 67", isPending = false), availablePayoutMethods = listOf(MemberPaymentProvider.SWISH), ), Content( - currentMethod = PayoutAccount.SwishPayout(phoneNumber = "070-123 45 67", isPending = false), + currentMethod = SwishPayout(phoneNumber = "070-123 45 67", isPending = false), availablePayoutMethods = listOf(MemberPaymentProvider.SWISH, MemberPaymentProvider.TRUSTLY), ), Content( - currentMethod = PayoutAccount.SwishPayout(phoneNumber = null, isPending = true), + currentMethod = SwishPayout(phoneNumber = null, isPending = true), availablePayoutMethods = listOf(MemberPaymentProvider.SWISH), ), Content( - currentMethod = PayoutAccount.SwishPayout(phoneNumber = "070-123 45 67", isPending = true), + currentMethod = SwishPayout(phoneNumber = "070-123 45 67", isPending = true), availablePayoutMethods = listOf(MemberPaymentProvider.SWISH), ), Content( - currentMethod = PayoutAccount.Trustly( + currentMethod = Trustly( clearingNumber = "8327", accountNumber = "12345678", bankName = "Mock Swedbank", @@ -294,7 +303,7 @@ private class PayoutAccountOverviewUiStateProvider : CollectionPreviewParameterP availablePayoutMethods = listOf(MemberPaymentProvider.TRUSTLY), ), Content( - currentMethod = PayoutAccount.BankAccount( + currentMethod = BankAccount( clearingNumber = "3300", accountNumber = "1234567", bankName = "Nordea", @@ -303,7 +312,7 @@ private class PayoutAccountOverviewUiStateProvider : CollectionPreviewParameterP availablePayoutMethods = listOf(MemberPaymentProvider.NORDEA), ), Content( - currentMethod = PayoutAccount.BankAccount( + currentMethod = BankAccount( clearingNumber = null, accountNumber = null, bankName = null, @@ -312,7 +321,7 @@ private class PayoutAccountOverviewUiStateProvider : CollectionPreviewParameterP availablePayoutMethods = listOf(MemberPaymentProvider.NORDEA), ), Content( - currentMethod = PayoutAccount.BankAccount( + currentMethod = BankAccount( clearingNumber = "3300", accountNumber = "1234567", bankName = "Nordea", @@ -320,21 +329,5 @@ private class PayoutAccountOverviewUiStateProvider : CollectionPreviewParameterP ), availablePayoutMethods = listOf(MemberPaymentProvider.NORDEA), ), - Content( - currentMethod = PayoutAccount.Invoice( - delivery = PaymentMethodInvoiceDelivery.KIVRA, - email = null, - isPending = false, - ), - availablePayoutMethods = listOf(MemberPaymentProvider.INVOICE), - ), - Content( - currentMethod = PayoutAccount.Invoice( - delivery = PaymentMethodInvoiceDelivery.MAIL, - email = "user@example.com", - isPending = false, - ), - availablePayoutMethods = listOf(MemberPaymentProvider.INVOICE, MemberPaymentProvider.TRUSTLY), - ), ), ) diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/selectmethod/SelectPayoutMethodDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/selectmethod/SelectPayoutMethodDestination.kt index 691e3a936c..3e6fbf7fdb 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/selectmethod/SelectPayoutMethodDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/selectmethod/SelectPayoutMethodDestination.kt @@ -1,28 +1,38 @@ package com.hedvig.android.feature.payoutaccount.ui.selectmethod -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp -import com.hedvig.android.design.system.hedvig.HedvigCard +import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigScaffold -import com.hedvig.android.design.system.hedvig.HedvigText +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigTextButton import com.hedvig.android.design.system.hedvig.HedvigTheme +import com.hedvig.android.design.system.hedvig.RadioGroup +import com.hedvig.android.design.system.hedvig.RadioOption +import com.hedvig.android.design.system.hedvig.RadioOptionId +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.design.system.hedvig.a11y.FlowHeading +import com.hedvig.android.feature.payoutaccount.ui.components.PayoutMethodHandoverIllustration +import com.hedvig.android.feature.payoutaccount.ui.components.PayoutProviderPillow import hedvig.resources.BANK_PAYOUT_METHOD_CARD_DESCRIPTION import hedvig.resources.BANK_PAYOUT_METHOD_CARD_TITLE -import hedvig.resources.PAYMENTS_INVOICE -import hedvig.resources.PAYOUT_METHOD_INVOICE_DESCRIPTION import hedvig.resources.PAYOUT_METHOD_SWISH_DESCRIPTION import hedvig.resources.PAYOUT_METHOD_TRUSTLY_DESCRIPTION import hedvig.resources.PAYOUT_SELECT_PAYOUT_METHOD -import hedvig.resources.Res +import hedvig.resources.Res.string +import hedvig.resources.general_cancel_button +import hedvig.resources.general_continue_button import hedvig.resources.swish import hedvig.resources.trustly import octopus.type.MemberPaymentProvider @@ -34,69 +44,136 @@ internal fun SelectPayoutMethodDestination( onTrustlySelected: () -> Unit, onNordeaSelected: () -> Unit, onSwishSelected: () -> Unit, - onInvoiceSelected: () -> Unit, + navigateUp: () -> Unit, +) { + // Held as the raw value so the choice survives process death without a saver of its own. + var selectedRawValue by rememberSaveable { mutableStateOf(null) } + val selectedProvider = selectedRawValue?.let { MemberPaymentProvider.safeValueOf(it) } + SelectPayoutMethodScreen( + availableProviders = availableProviders, + selectedProvider = selectedProvider, + onProviderSelected = { selectedRawValue = it.rawValue }, + onSubmitSelected = { + when (selectedProvider) { + MemberPaymentProvider.TRUSTLY -> { + onTrustlySelected() + } + + MemberPaymentProvider.NORDEA -> { + onNordeaSelected() + } + + MemberPaymentProvider.SWISH -> { + onSwishSelected() + } + + else -> {} + } + }, + navigateUp = navigateUp, + ) +} + +@Composable +private fun SelectPayoutMethodScreen( + availableProviders: List, + selectedProvider: MemberPaymentProvider?, + onProviderSelected: (MemberPaymentProvider) -> Unit, + onSubmitSelected: () -> Unit, navigateUp: () -> Unit, ) { HedvigScaffold( - topAppBarText = stringResource(Res.string.PAYOUT_SELECT_PAYOUT_METHOD), + topAppBarText = null, navigateUp = navigateUp, modifier = Modifier.fillMaxSize(), ) { Spacer(Modifier.height(8.dp)) - Column(Modifier.padding(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { - for (provider in availableProviders) { - when (provider) { - MemberPaymentProvider.TRUSTLY -> { - PayoutMethodRow( - title = stringResource(Res.string.trustly), - subtitle = stringResource(Res.string.PAYOUT_METHOD_TRUSTLY_DESCRIPTION), - onClick = onTrustlySelected, - ) - } + FlowHeading( + title = stringResource(string.PAYOUT_SELECT_PAYOUT_METHOD), + description = null, + baseStyle = HedvigTheme.typography.bodySmall, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.weight(1f)) + PayoutMethodHandoverIllustration( + selectedProvider, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) + Spacer(Modifier.weight(1f)) + RadioGroup( + options = availableProviders.mapNotNull { it.toRadioOption() }, + selectedOption = selectedProvider?.let { RadioOptionId(it.rawValue) }, + onRadioOptionSelected = { onProviderSelected(MemberPaymentProvider.safeValueOf(it.id)) }, + optionIcon = { PayoutProviderPillow(MemberPaymentProvider.safeValueOf(it.id)) }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + HedvigButton( + onClick = onSubmitSelected, + enabled = selectedProvider != null, + text = stringResource(string.general_continue_button), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(8.dp)) + HedvigTextButton( + onClick = navigateUp, + enabled = true, + text = stringResource(string.general_cancel_button), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) + } +} - MemberPaymentProvider.NORDEA -> { - PayoutMethodRow( - title = stringResource(Res.string.BANK_PAYOUT_METHOD_CARD_TITLE), - subtitle = stringResource(Res.string.BANK_PAYOUT_METHOD_CARD_DESCRIPTION), - onClick = onNordeaSelected, - ) - } +/** + * Null for providers this screen has no option for, so a value the backend adds later is left out + * of the group. + */ +@Composable +private fun MemberPaymentProvider.toRadioOption(): RadioOption? { + val id = RadioOptionId(rawValue) + return when (this) { + MemberPaymentProvider.TRUSTLY -> RadioOption( + id = id, + text = stringResource(string.trustly), + label = stringResource(string.PAYOUT_METHOD_TRUSTLY_DESCRIPTION), + ) - MemberPaymentProvider.SWISH -> { - PayoutMethodRow( - title = stringResource(Res.string.swish), - subtitle = stringResource(Res.string.PAYOUT_METHOD_SWISH_DESCRIPTION), - onClick = onSwishSelected, - ) - } + MemberPaymentProvider.NORDEA -> RadioOption( + id = id, + text = stringResource(string.BANK_PAYOUT_METHOD_CARD_TITLE), + label = stringResource(string.BANK_PAYOUT_METHOD_CARD_DESCRIPTION), + ) - MemberPaymentProvider.INVOICE -> { - PayoutMethodRow( - title = stringResource(Res.string.PAYMENTS_INVOICE), - subtitle = stringResource(Res.string.PAYOUT_METHOD_INVOICE_DESCRIPTION), - onClick = onInvoiceSelected, - ) - } + MemberPaymentProvider.SWISH -> RadioOption( + id = id, + text = stringResource(string.swish), + label = stringResource(string.PAYOUT_METHOD_SWISH_DESCRIPTION), + ) - else -> {} - } - } - } - Spacer(Modifier.height(16.dp)) + else -> null } } @Composable -private fun PayoutMethodRow(title: String, subtitle: String, onClick: () -> Unit, modifier: Modifier = Modifier) { - HedvigCard( - onClick = onClick, - modifier = modifier.fillMaxWidth(), - ) { - Column(Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) { - HedvigText(text = title) - HedvigText( - text = subtitle, - color = HedvigTheme.colorScheme.textSecondary, +@HedvigShortMultiScreenPreview +private fun PreviewSelectPayoutMethodScreen() { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + SelectPayoutMethodScreen( + availableProviders = listOf( + MemberPaymentProvider.SWISH, + MemberPaymentProvider.TRUSTLY, + MemberPaymentProvider.NORDEA, + ), + selectedProvider = MemberPaymentProvider.SWISH, + onProviderSelected = {}, + onSubmitSelected = {}, + navigateUp = {}, ) } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt index 04b937db48..0a7d07c257 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutDestination.kt @@ -4,44 +4,46 @@ import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hedvig.android.compose.ui.preview.BooleanCollectionPreviewParameterProvider import com.hedvig.android.core.common.validation.PhoneNumberRules -import com.hedvig.android.design.system.hedvig.GlobalSnackBarState import com.hedvig.android.design.system.hedvig.HedvigButton import com.hedvig.android.design.system.hedvig.HedvigNotificationCard import com.hedvig.android.design.system.hedvig.HedvigScaffold +import com.hedvig.android.design.system.hedvig.HedvigShortMultiScreenPreview +import com.hedvig.android.design.system.hedvig.HedvigTheme import com.hedvig.android.design.system.hedvig.NotificationDefaults.NotificationPriority +import com.hedvig.android.design.system.hedvig.Surface +import com.hedvig.android.feature.payoutaccount.ui.components.PayoutSetupSuccessContent import com.hedvig.android.ui.phonenumber.HedvigPhoneNumberField -import hedvig.resources.CONTACT_INFO_CHANGES_SAVED import hedvig.resources.ODYSSEY_PHONE_NUMBER_LABEL +import hedvig.resources.PAYMENT_SWISH_SUCCESS_TITLE import hedvig.resources.Res import hedvig.resources.TIER_FLOW_COMMIT_PROCESSING_ERROR_DESCRIPTION import hedvig.resources.general_save_button import hedvig.resources.swish +import octopus.type.MemberPaymentProvider import org.jetbrains.compose.resources.stringResource @Composable -internal fun SetupSwishPayoutDestination( - viewModel: SetupSwishPayoutViewModel, - globalSnackBarState: GlobalSnackBarState, - navigateUp: () -> Unit, -) { +internal fun SetupSwishPayoutDestination(viewModel: SetupSwishPayoutViewModel, navigateUp: () -> Unit) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() SetupSwishPayoutScreen( uiState = uiState, - globalSnackBarState = globalSnackBarState, onSave = { viewModel.emit(SetupSwishPayoutEvent.Save) }, - showedSnackBar = { viewModel.emit(SetupSwishPayoutEvent.ShowedSnackBar) }, + onFinishSetup = { viewModel.emit(SetupSwishPayoutEvent.FinishSetup) }, navigateUp = navigateUp, ) } @@ -49,58 +51,85 @@ internal fun SetupSwishPayoutDestination( @Composable private fun SetupSwishPayoutScreen( uiState: SetupSwishPayoutUiState, - globalSnackBarState: GlobalSnackBarState, onSave: () -> Unit, - showedSnackBar: () -> Unit, + onFinishSetup: () -> Unit, navigateUp: () -> Unit, ) { - val changesSaved = stringResource(Res.string.CONTACT_INFO_CHANGES_SAVED) - LaunchedEffect(uiState.showSuccessSnackBar) { - if (!uiState.showSuccessSnackBar) return@LaunchedEffect - globalSnackBarState.show(changesSaved, NotificationPriority.Campaign) - showedSnackBar() - } - HedvigScaffold( topAppBarText = stringResource(Res.string.swish), navigateUp = navigateUp, modifier = Modifier.fillMaxSize(), ) { - Spacer(Modifier.weight(1f)) - Column(Modifier.padding(horizontal = 16.dp)) { - HedvigPhoneNumberField( - state = uiState.phoneNumberState, - labelText = stringResource(Res.string.ODYSSEY_PHONE_NUMBER_LABEL), - rules = PhoneNumberRules.SwishPhoneNumber, - modifier = Modifier.fillMaxWidth(), - ) - } - AnimatedVisibility( - visible = uiState.errorMessage != null, - enter = expandVertically(), - exit = shrinkVertically(), - ) { - HedvigNotificationCard( - message = uiState.errorMessage?.message - ?: stringResource(Res.string.TIER_FLOW_COMMIT_PROCESSING_ERROR_DESCRIPTION), - priority = NotificationPriority.Attention, - modifier = Modifier - .padding(horizontal = 16.dp) - .padding(top = 4.dp) - .fillMaxWidth(), + if (uiState.isConnected) { + PayoutSetupSuccessContent( + provider = MemberPaymentProvider.SWISH, + title = stringResource(Res.string.PAYMENT_SWISH_SUCCESS_TITLE), + onContinue = onFinishSetup, ) + } else { + PhoneNumberContent(uiState = uiState, onSave = onSave) } - Spacer(Modifier.height(16.dp)) - HedvigButton( - text = stringResource(Res.string.general_save_button), - onClick = onSave, - enabled = !uiState.isLoading && - PhoneNumberRules.SwishPhoneNumber.hasEnoughDigits(uiState.phoneNumberState.text), - isLoading = uiState.isLoading, + } +} + +@Composable +private fun ColumnScope.PhoneNumberContent(uiState: SetupSwishPayoutUiState, onSave: () -> Unit) { + Spacer(Modifier.weight(1f)) + Column(Modifier.padding(horizontal = 16.dp)) { + HedvigPhoneNumberField( + state = uiState.phoneNumberState, + labelText = stringResource(Res.string.ODYSSEY_PHONE_NUMBER_LABEL), + rules = PhoneNumberRules.SwishPhoneNumber, + modifier = Modifier.fillMaxWidth(), + ) + } + AnimatedVisibility( + visible = uiState.errorMessage != null, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + HedvigNotificationCard( + message = uiState.errorMessage?.message + ?: stringResource(Res.string.TIER_FLOW_COMMIT_PROCESSING_ERROR_DESCRIPTION), + priority = NotificationPriority.Attention, modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp), + .padding(horizontal = 16.dp) + .padding(top = 4.dp) + .fillMaxWidth(), ) - Spacer(Modifier.height(16.dp)) + } + Spacer(Modifier.height(16.dp)) + HedvigButton( + text = stringResource(Res.string.general_save_button), + onClick = onSave, + enabled = !uiState.isLoading && + PhoneNumberRules.SwishPhoneNumber.hasEnoughDigits(uiState.phoneNumberState.text), + isLoading = uiState.isLoading, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + ) + Spacer(Modifier.height(16.dp)) +} + +@Composable +@HedvigShortMultiScreenPreview +private fun PreviewSetupSwishPayoutScreenConnected( + @PreviewParameter(BooleanCollectionPreviewParameterProvider::class) isConnected: Boolean, +) { + HedvigTheme { + Surface(color = HedvigTheme.colorScheme.backgroundPrimary) { + SetupSwishPayoutScreen( + uiState = SetupSwishPayoutUiState( + phoneNumberState = TextFieldState(), + isLoading = false, + errorMessage = null, + isConnected = isConnected, + ), + onSave = {}, + onFinishSetup = {}, + navigateUp = {}, + ) + } } } diff --git a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt index 468c1b05c9..2a32011f5e 100644 --- a/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt +++ b/app/feature/feature-payout-account/src/main/kotlin/com/hedvig/android/feature/payoutaccount/ui/setupswish/SetupSwishPayoutViewModel.kt @@ -32,14 +32,14 @@ internal class SetupSwishPayoutViewModel( internal sealed interface SetupSwishPayoutEvent { data object Save : SetupSwishPayoutEvent - data object ShowedSnackBar : SetupSwishPayoutEvent + data object FinishSetup : SetupSwishPayoutEvent } internal data class SetupSwishPayoutUiState( val phoneNumberState: TextFieldState, val isLoading: Boolean, val errorMessage: ErrorMessage?, - val showSuccessSnackBar: Boolean, + val isConnected: Boolean, ) internal class SetupSwishPayoutPresenter( @@ -53,7 +53,7 @@ internal class SetupSwishPayoutPresenter( val phoneNumberState = remember { lastState.phoneNumberState } var isLoading by remember { mutableStateOf(false) } var errorMessage by remember { mutableStateOf(null) } - var showSuccessSnackBar by remember { mutableStateOf(false) } + var isConnected by remember { mutableStateOf(false) } var saveIteration by remember { mutableStateOf(null) } val currentSave = saveIteration @@ -69,7 +69,7 @@ internal class SetupSwishPayoutPresenter( }, ifRight = { isLoading = false - showSuccessSnackBar = true + isConnected = true saveIteration = null }, ) @@ -84,7 +84,7 @@ internal class SetupSwishPayoutPresenter( } } - SetupSwishPayoutEvent.ShowedSnackBar -> { + SetupSwishPayoutEvent.FinishSetup -> { backstack.popUpTo(inclusive = true) } } @@ -94,7 +94,7 @@ internal class SetupSwishPayoutPresenter( phoneNumberState = phoneNumberState, isLoading = isLoading, errorMessage = errorMessage, - showSuccessSnackBar = showSuccessSnackBar, + isConnected = isConnected, ) } } diff --git a/app/member-reminders/member-reminders-ui/src/main/kotlin/com/hedvig/android/memberreminders/ui/MemberReminderCards.kt b/app/member-reminders/member-reminders-ui/src/main/kotlin/com/hedvig/android/memberreminders/ui/MemberReminderCards.kt index bfe6cd3607..fb26630729 100644 --- a/app/member-reminders/member-reminders-ui/src/main/kotlin/com/hedvig/android/memberreminders/ui/MemberReminderCards.kt +++ b/app/member-reminders/member-reminders-ui/src/main/kotlin/com/hedvig/android/memberreminders/ui/MemberReminderCards.kt @@ -366,7 +366,7 @@ private fun ReminderCardConnectPayment( modifier = modifier, priority = NotificationPriority.Attention, style = InfoCardStyle.Button( - buttonText = stringResource(Res.string.PROFILE_PAYMENT_CONNECT_DIRECT_DEBIT_BUTTON), + buttonText = "Setup payment method", // todo onButtonClick = navigateToConnectPayment, ), minLines = minLines, diff --git a/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt b/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt index dd648efb8b..d12fcdd887 100644 --- a/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt +++ b/app/navigation/navigation-core/src/commonMain/kotlin/com/hedvig/android/navigation/core/HedvigDeepLinkContainer.kt @@ -43,9 +43,9 @@ interface HedvigDeepLinkContainer { val profile: List // The profile screen, which acts as a gateway to several app settings - // Screen where the member can connect their payment method to Hedvig to pay for insurance + // Payin account overview, where the member sees their current payin method and can change it val connectPayment: List - val directDebit: List // Same as connectPayment but to support an old link to it + val directDebit: List // Goes straight into the Trustly direct debit connection flow val eurobonus: List // The destination allowing to edit your current Eurobonus (SAS) number val payments: List // The payments screen, showing the payments history and the upcoming payment information val payout: List // Payout connection overview screen, can see existing options and change/add payout options diff --git a/app/shared/member-quick-actions/build.gradle.kts b/app/shared/member-quick-actions/build.gradle.kts index b03e4972ae..75fe8cf482 100644 --- a/app/shared/member-quick-actions/build.gradle.kts +++ b/app/shared/member-quick-actions/build.gradle.kts @@ -47,6 +47,7 @@ kotlin { implementation(projects.featureHelpCenterNavigation) implementation(projects.featureMovingflowNavigation) implementation(projects.featurePaymentsNavigation) + implementation(projects.featurePayinAccountNavigation) implementation(projects.featureTerminateInsuranceNavigation) implementation(projects.featureTravelCertificateNavigation) } diff --git a/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/GetMemberQuickActionsUseCase.kt b/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/GetMemberQuickActionsUseCase.kt index b39a8e3c78..cd659d9e44 100644 --- a/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/GetMemberQuickActionsUseCase.kt +++ b/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/GetMemberQuickActionsUseCase.kt @@ -44,6 +44,7 @@ import hedvig.resources.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_SUBTITLE import hedvig.resources.HC_QUICK_ACTIONS_UPGRADE_COVERAGE_TITLE import hedvig.resources.HOME_QUICK_ACTIONS_CHANGE_ADDRESS import hedvig.resources.HOME_QUICK_ACTIONS_EDIT_INSURANCE +import hedvig.resources.MANAGE_BILLING_METHODS_BUTTON import hedvig.resources.HOME_QUICK_ACTIONS_INVITE import hedvig.resources.HOME_QUICK_ACTIONS_UPCOMING_PAYMENT import hedvig.resources.Res @@ -153,7 +154,7 @@ internal class GetMemberQuickActionsUseCaseImpl( QuickAction.StandaloneQuickLink( quickLinkDestination = QuickLinkDestination.OuterDestination.QuickLinkConnectPayment, titleRes = Res.string.HC_QUICK_ACTIONS_PAYMENTS_TITLE, - hintTextRes = Res.string.HC_QUICK_ACTIONS_PAYMENTS_SUBTITLE, + hintTextRes = Res.string.MANAGE_BILLING_METHODS_BUTTON, // todo!!! ), ) } diff --git a/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/QuickLinkNavKey.kt b/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/QuickLinkNavKey.kt index 40475558a0..1256693481 100644 --- a/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/QuickLinkNavKey.kt +++ b/app/shared/member-quick-actions/src/commonMain/kotlin/com/hedvig/android/memberquickactions/QuickLinkNavKey.kt @@ -2,7 +2,6 @@ package com.hedvig.android.memberquickactions import com.hedvig.android.data.coinsured.CoInsuredFlowType import com.hedvig.android.feature.change.tier.navigation.StartTierFlowChooseInsuranceKey -import com.hedvig.android.feature.connect.payment.trustly.ui.TrustlyKey import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddInfoKey import com.hedvig.android.feature.editcoinsured.navigation.CoInsuredAddOrRemoveKey import com.hedvig.android.feature.editcoinsured.navigation.EditCoInsuredTriageKey @@ -11,6 +10,7 @@ import com.hedvig.android.feature.help.center.navigation.EmergencyKey import com.hedvig.android.feature.movingflow.MovingSource import com.hedvig.android.feature.movingflow.SelectContractForMovingKey import com.hedvig.android.feature.payments.navigation.PaymentDetailsKey +import com.hedvig.android.feature.payin.account.navigation.PayinAccountKey import com.hedvig.android.feature.terminateinsurance.navigation.TerminateInsuranceKey import com.hedvig.android.feature.travelcertificate.navigation.TravelCertificateKey import com.hedvig.android.memberquickactions.QuickLinkDestination.OuterDestination.ChooseInsuranceForEditCoInsured @@ -54,7 +54,7 @@ fun QuickLinkDestination.OuterDestination.toNavKey(): HedvigNavKey = when (this) } QuickLinkConnectPayment -> { - TrustlyKey + PayinAccountKey } QuickLinkTermination -> { diff --git a/hedvig-lint/lint-baseline/lint-baseline-feature-payin-account.xml b/hedvig-lint/lint-baseline/lint-baseline-feature-payin-account.xml new file mode 100644 index 0000000000..a5d7f6b144 --- /dev/null +++ b/hedvig-lint/lint-baseline/lint-baseline-feature-payin-account.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + + + + + + + + + + + +