Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ fun appEntryProvider(
}

// User Profile Management

annotatedEntry<AppRoute.UpdateUserProfile> { key ->
UpdateUserProfileFlowScreen(route = key, resultStateRegistry = resultStateRegistry)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.flipcash.app.android.R
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.app.core.navigation.DeeplinkAction
import com.flipcash.app.core.navigation.homeRoute
import com.flipcash.app.core.extensions.navigateAll
Expand Down Expand Up @@ -194,9 +195,7 @@ internal fun buildNavGraphForLaunch(
listOf(
AppRoute.UpdateUserProfile(
origin = AppRoute.OnboardingFlow(),
nameSource = DisplayNameSource.Onboarding,
includeName = true,
includePhoto = false,
steps = listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)),
target = AppRoute.OnboardingFlow(
phase = AppRoute.OnboardingFlow.Phase.Permissions,
skipContacts = true,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.flipcash.app.internal.ui.navigation

import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.services.models.chat.ChatId
import com.getcode.solana.keys.Mint
Expand Down Expand Up @@ -167,8 +169,10 @@ class BuildNavGraphForLaunchTest {
fun `onboarding at DisplayName resume point routes to display name entry then permissions`() {
val result = build(AuthState.Onboarding(AuthState.ResumePoint.DisplayName))!!
val route = assertIs<AppRoute.UpdateUserProfile>(result.baseRoutes.single())
assertTrue(route.includeName)
assertEquals(false, route.includePhoto)
assertEquals(
listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)),
route.steps,
)
val target = assertIs<AppRoute.OnboardingFlow>(route.target)
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,8 @@ sealed interface TutorialItem {
}

/**
* Drawn but inert. Nothing backs a user-set minimum tip yet: the amount comes from
* server-supplied regional presets, no field for it exists on the profile or the tip-card
* customization message, and iOS has no implementation either. The row is in the design, so
* it is drawn — and it never completes, which is the state node 9641:17019 shows.
* The fee another user has to pay to open a DM, stored on the profile as `minDmChatInitFee`.
* Completes once one is set; the row stays outstanding while the server default applies.
*/
class MinimumTip(override val isCompleted: Boolean = false) : Profile {
override val title: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,21 +144,20 @@ sealed interface AppRoute : NavKey, Parcelable {

@Serializable
@Parcelize
/**
* The profile editor, as an ordered list of the [steps] the caller wants. Each step carries its
* own parameters, so asking for a subset costs nothing beyond a shorter list.
*/
data class UpdateUserProfile(
val origin: AppRoute,
val nameSource: DisplayNameSource,
val includeName: Boolean = true,
val includePhoto: Boolean = true,
// Off by default: the username step is gated on a minimum balance and is never part of
// onboarding, so only the surfaces that qualify the account ask for it.
val includeUsername: Boolean = false,
val steps: List<UpdateProfileStep>,
val target: AppRoute? = null,
// When false, the first step has no back affordance and system back is swallowed —
// used in onboarding where display-name entry is a mandatory, non-dismissable step.
val allowBack: Boolean = true,
): AppRoute, FlowRouteWithResult<UpdateProfileResult> {
override val initialStack: List<NavKey>
get() = buildUpdateUserProfileStack(includeName, includeUsername, includePhoto)
get() = steps
}

@Serializable
Expand Down Expand Up @@ -371,16 +370,6 @@ private fun buildVerificationInitialStack(

// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name, then
// username, then photo. In edit mode only the requested step(s) are included.
private fun buildUpdateUserProfileStack(
includeName: Boolean,
includeUsername: Boolean,
includePhoto: Boolean,
): List<NavKey> = buildList {
if (includeName) add(UpdateProfileStep.Name)
if (includeUsername) add(UpdateProfileStep.Username)
if (includePhoto) add(UpdateProfileStep.Photo)
}

/** Where a display-name entry flow was launched from. Reported as the `Source` analytics property. */
@Serializable
enum class DisplayNameSource { Onboarding, MyAccount, TipCardSetup }
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
package com.flipcash.app.core.userprofile

import android.os.Parcelable
import com.flipcash.app.core.DisplayNameSource
import com.getcode.navigation.flow.FlowStep
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable

/**
* One screen of the profile editor. A caller asks for the steps it wants, in order, and each step
* carries whatever it alone needs — so a flow that skips the name step never has to name a
* [DisplayNameSource] for it.
*/
@Serializable
sealed interface UpdateProfileStep : FlowStep, Parcelable {
/** @param source where the entry was launched from; reported as the `Source` analytics property. */
@Parcelize
@Serializable
object Name : UpdateProfileStep
data class Name(val source: DisplayNameSource) : UpdateProfileStep

/**
* Claiming the public `@handle`. Optional and off by default: unlike the display name it is
* never part of onboarding — the server gates it behind a minimum balance, so it is reached
* from My Account or the "You" tab once the account qualifies.
* Claiming the public `@handle`. Never part of onboarding — the server gates it behind a
* minimum balance, so it is reached from My Account or the "You" tab once the account
* qualifies.
*/
@Parcelize
@Serializable
Expand All @@ -24,5 +31,11 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable {
@Serializable
object Photo : UpdateProfileStep


/**
* The fee another user has to pay to open a DM, which the profile carries as
* `minDmChatInitFee`.
*/
@Parcelize
@Serializable
object MinimumTip : UpdateProfileStep
}
11 changes: 11 additions & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,17 @@
<string name="error_description_tipMinimum">Increase the amount to send</string>
<string name="label_viaTipCard">via Tip Card</string>

<!-- Minimum-tip entry (nodes 9541:10951, 9553:113170). Backed by the profile's
min_dm_chat_init_fee: tipping is what opens a DM, so the fee to start one is the
minimum tip. Distinct from the send-side strings above, which are about a tip in flight. -->
<string name="title_minimumTipEntry">Set Minimum Tip</string>
<string name="title_minimumTip">Minimum Tip</string>
<string name="subtitle_minimumTipHint">%1$s minimum</string>
<string name="error_title_minimumTip">%1$s Minimum Tip</string>
<string name="error_description_minimumTip">Please enter a higher amount</string>
<string name="error_title_minimumTipFailed">Something Went Wrong</string>
<string name="error_description_minimumTipFailed">We were unable to save your minimum tip. Please try again</string>

<string name="title_block">Block</string>
<string name="title_blocklist">Blocked</string>
<string name="action_block">Block</string>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import com.flipcash.app.analytics.Action
import com.flipcash.app.analytics.Button
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.app.core.LocalUserManager
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.core.navigation.homeRoute
Expand Down Expand Up @@ -267,9 +268,7 @@ private fun FlowNavigator<OnboardingStep, OnboardingResult>.proceedToNameOrPermi
navigate(
AppRoute.UpdateUserProfile(
origin = AppRoute.OnboardingFlow(),
nameSource = DisplayNameSource.Onboarding,
includeName = true,
includePhoto = false,
steps = listOf(UpdateProfileStep.Name(DisplayNameSource.Onboarding)),
target = AppRoute.OnboardingFlow(
phase = AppRoute.OnboardingFlow.Phase.Permissions,
skipContacts = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,9 @@ internal fun MenuScreenContent(viewModel: MenuScreenViewModel) {
onSetProfilePicture = {
viewModel.dispatchEvent(Event.SetProfilePicture)
},
onSetMinimumTip = {
viewModel.dispatchEvent(Event.SetMinimumTip)
},
)
},
footer = {
Expand Down Expand Up @@ -417,6 +420,7 @@ private fun YouHeader(
onClaimUsername: () -> Unit,
profileTutorial: List<TutorialItem.Profile>?,
onSetProfilePicture: () -> Unit,
onSetMinimumTip: () -> Unit,
) {
when (tipCardState) {
TipCardState.Unknown -> Unit
Expand Down Expand Up @@ -445,6 +449,7 @@ private fun YouHeader(
onClaimUsername = onClaimUsername,
profileTutorial = profileTutorial,
onSetProfilePicture = onSetProfilePicture,
onSetMinimumTip = onSetMinimumTip,
)
}
}
Expand Down Expand Up @@ -480,6 +485,7 @@ private fun ClaimedTipCard(
onClaimUsername: () -> Unit,
profileTutorial: List<TutorialItem.Profile>?,
onSetProfilePicture: () -> Unit,
onSetMinimumTip: () -> Unit,
) {
Column(
modifier = Modifier.fillMaxWidth(),
Expand Down Expand Up @@ -565,8 +571,7 @@ private fun ClaimedTipCard(
) { item ->
when (item) {
is TutorialItem.ProfilePicture -> onSetProfilePicture()
// Inert: nothing backs a user-set minimum tip yet.
is TutorialItem.MinimumTip -> Unit
is TutorialItem.MinimumTip -> onSetMinimumTip()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import com.flipcash.app.bills.share.TipCodePreviewCache
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.android.VersionInfo
import com.flipcash.app.core.DisplayNameSource
import com.flipcash.app.core.userprofile.UpdateProfileStep
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.extensions.setText
import com.flipcash.app.core.share.TipCodeExportFormat
Expand Down Expand Up @@ -168,6 +169,9 @@ internal class MenuScreenViewModel @Inject constructor(
/** The checklist's photo row — opens the photo step of the profile flow on its own. */
data object SetProfilePicture : Event

/** The checklist's minimum-tip row — opens the amount entry for the DM-init fee. */
data object SetMinimumTip : Event

/** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */
data object ClaimUsername : Event
/** The claim prompt's CTA — collect a display name so the account gets a real card. */
Expand Down Expand Up @@ -323,11 +327,7 @@ internal class MenuScreenViewModel @Inject constructor(
Event.OpenScreen(
AppRoute.UpdateUserProfile(
origin = AppRoute.Sheets.Menu,
// Inert: the name step is skipped, but the route asks for a source.
nameSource = DisplayNameSource.TipCardSetup,
includeName = false,
includePhoto = false,
includeUsername = true,
steps = listOf(UpdateProfileStep.Username),
)
)
)
Expand Down Expand Up @@ -374,10 +374,8 @@ internal class MenuScreenViewModel @Inject constructor(
Event.OpenScreen(
AppRoute.UpdateUserProfile(
origin = AppRoute.Sheets.Menu,
nameSource = DisplayNameSource.TipCardSetup,
includeName = true,
// Explicitly false: a name is all a tip card needs.
includePhoto = false,
// A name is all a tip card needs.
steps = listOf(UpdateProfileStep.Name(DisplayNameSource.TipCardSetup)),
)
)
)
Expand All @@ -391,12 +389,23 @@ internal class MenuScreenViewModel @Inject constructor(
Event.OpenScreen(
AppRoute.UpdateUserProfile(
origin = AppRoute.Sheets.Menu,
nameSource = DisplayNameSource.MyAccount,
// Photo only: the account already has a name and a card by the time
// this checklist is drawn, so the flow reduces to the one step.
includeName = false,
includePhoto = true,
includeUsername = false,
// The account already has a name and a card by the time this
// checklist is drawn, so the flow reduces to the one step.
steps = listOf(UpdateProfileStep.Photo),
)
)
)
}
.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.SetMinimumTip>()
.onEach {
dispatchEvent(
Event.OpenScreen(
AppRoute.UpdateUserProfile(
origin = AppRoute.Sheets.Menu,
steps = listOf(UpdateProfileStep.MinimumTip),
)
)
)
Expand Down Expand Up @@ -567,6 +576,7 @@ internal class MenuScreenViewModel @Inject constructor(
Event.ClaimTipCard,
Event.ClaimUsername,
Event.SetProfilePicture,
Event.SetMinimumTip,
Event.ShareTipCard,
Event.CopyTipLink,
Event.DownloadTipCard,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import com.flipcash.services.models.UserProfile
* The "Finish Your Profile" checklist for the "You" tab (node 9544:18140).
*
* Null while the profile is unresolved, so the card is never drawn against a guess — an account
* that already has a photo would otherwise flash an outstanding step on the way in.
* that already has a photo would otherwise flash an outstanding step on the way in. Null again once
* every step is done: a checklist with nothing left to do is just a row of ticks.
*
* The minimum-tip step is always outstanding; see [TutorialItem.MinimumTip].
* Both steps read straight off the profile, so a step completed elsewhere — My Account's own
* Minimum Tip row, say — closes here too.
*/
internal fun profileTutorialItems(profile: UserProfile?): List<TutorialItem.Profile>? {
profile ?: return null
return listOf(
val items = listOf(
TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null),
TutorialItem.MinimumTip(),
TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null),
)
return items.takeUnless { steps -> steps.all { it.isCompleted } }
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.flipcash.app.menu.internal
import com.flipcash.app.core.ui.onboarding.TutorialItem
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.chat.MediaItem
import com.getcode.opencode.model.financial.Fiat
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
Expand All @@ -14,12 +15,13 @@ class ProfileTutorialTest {
// "a picture is set" without needing a mocking library in this module.
private val anyPicture = MediaItem(renditions = emptyList())

private fun profile(picture: MediaItem?) = UserProfile(
private fun profile(picture: MediaItem? = null, minimumTip: Fiat? = null) = UserProfile(
displayName = "Brandon",
socialAccounts = emptyList(),
phoneNumber = null,
email = null,
profilePicture = picture,
minDmChatInitFee = minimumTip,
)

@Test
Expand All @@ -28,8 +30,8 @@ class ProfileTutorialTest {
}

@Test
fun `a profile without a picture leaves both steps outstanding`() {
val items = profileTutorialItems(profile(picture = null))
fun `a bare profile leaves both steps outstanding`() {
val items = profileTutorialItems(profile())
assertEquals(2, items?.size)
assertTrue(items!!.none { it.isCompleted })
}
Expand All @@ -42,8 +44,14 @@ class ProfileTutorialTest {
}

@Test
fun `the minimum tip step never completes`() {
val items = profileTutorialItems(profile(picture = anyPicture))
assertTrue(items!!.none { it is TutorialItem.MinimumTip && it.isCompleted })
fun `a saved minimum tip completes only the minimum tip step`() {
val items = profileTutorialItems(profile(minimumTip = Fiat(1.0)))
assertEquals(1, items?.count { it.isCompleted })
assertTrue(items!!.first { it is TutorialItem.MinimumTip }.isCompleted)
}

@Test
fun `a picture and a minimum tip take the checklist away entirely`() {
assertNull(profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0))))
}
}
Loading
Loading