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 @@ -28,6 +28,7 @@ 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.navigation.launchRoute
import com.flipcash.app.core.extensions.navigateAll
import com.flipcash.app.core.extensions.resolveBackStack
import com.flipcash.app.router.LocalRouter
Expand Down Expand Up @@ -199,6 +200,7 @@ internal fun buildNavGraphForLaunch(
target = AppRoute.OnboardingFlow(
phase = AppRoute.OnboardingFlow.Phase.Permissions,
skipContacts = true,
newAccount = true,
),
allowBack = false,
)
Expand Down Expand Up @@ -234,10 +236,11 @@ internal fun buildNavGraphForLaunch(
pendingAction = action,
)

else -> LaunchNavGraph(listOf(home))
// A link that resolves to nothing is a plain launch.
else -> LaunchNavGraph(listOf(launchRoute))
}
} else {
LaunchNavGraph(listOf(home))
LaunchNavGraph(listOf(launchRoute))
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,11 @@ class BuildNavGraphForLaunchTest {
// -- Ready --

@Test
fun `logged in without deeplink opens on the Wallet tab`() {
fun `logged in without deeplink opens on the Chats tab`() {
val result = build(AuthState.Ready)!!
assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes)
assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes)
assertTrue(result.deeplinkRoutes.isEmpty())
assertEquals(listOf(AppRoute.Sheets.Wallet), result.resolvedBackStack())
assertEquals(listOf(AppRoute.Sheets.Tips()), result.resolvedBackStack())
}

@Test
Expand Down Expand Up @@ -99,9 +99,9 @@ class BuildNavGraphForLaunchTest {
}

@Test
fun `logged in with None action opens the Wallet tab without deeplink routes`() {
fun `logged in with None action opens the Chats tab without deeplink routes`() {
val result = buildReady(DeeplinkAction.None)
assertEquals(listOf(AppRoute.Sheets.Wallet), result.baseRoutes)
assertEquals(listOf(AppRoute.Sheets.Tips()), result.baseRoutes)
assertTrue(result.deeplinkRoutes.isEmpty())
}

Expand Down Expand Up @@ -175,6 +175,8 @@ class BuildNavGraphForLaunchTest {
)
val target = assertIs<AppRoute.OnboardingFlow>(route.target)
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, target.phase)
// Display-name entry follows the access key, so this is an account still being created.
assertTrue(target.newAccount)
}

// -- Authenticating --
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ sealed interface AppRoute : NavKey, Parcelable {
val fromDeeplink: Boolean = false,
val resumeAt: ResumePoint = ResumePoint.Login,
val skipContacts: Boolean = true,
// Set when onboarding is creating the account rather than signing in to one; decides
// where the permissions phase releases to.
val newAccount: Boolean = false,
) : AppRoute, FlowRoute {
enum class Phase { Account, Permissions }
enum class ResumePoint { Login, AccessKey, AccessKeyThenPurchase, PostAccessKey }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,17 @@ package com.flipcash.app.core.navigation
import com.flipcash.app.core.AppRoute

/**
* The top-level route the app releases to once there's a session — on launch and when onboarding
* finishes.
* The top-level route the app releases to when onboarding finishes creating a new account, and
* under a launch deeplink (whose token and cash-link actions open on the wallet).
*
* The wallet is a flat tab home (not a sheet), so this route is applied with `replaceAll` /
* [com.getcode.navigation.core.NavOptions.PopUpTo.ClearAll], exactly like a tab switch from the
* nav bar.
*/
val homeRoute: AppRoute = AppRoute.Sheets.Wallet

/**
* The tab a cold launch opens on when no deeplink directs it elsewhere: Chats. Built from the tab's
* own destination so launching lands on the same route a tap on the Chats tab does.
*/
val launchRoute: AppRoute = NavBarButton.Chats.destinationRoute()
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,9 @@ class HomeRouteTest {
// hoisted nav bar would render with no selection.
assertEquals(NavBarButton.Wallet, homeRoute.asNavBarTab())
}

@Test
fun `cold launch opens on the chats tab`() {
assertEquals(NavBarButton.Chats, launchRoute.asNavBarTab())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ 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
import com.flipcash.app.core.navigation.launchRoute
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.LocalFeatureFlags
import com.flipcash.app.core.onboarding.OnboardingResult
Expand Down Expand Up @@ -113,7 +114,9 @@ import kotlin.time.Duration.Companion.milliseconds
* [PermissionsPhaseFlowHost].
* ² Display-name entry is shown only when no display name is set. It reuses the
* UpdateUserProfile subflow, whose `target` replaces the stack with the permissions phase.
* ³ Home is the same route the app launches on — the Wallet tab. See [homeRoute].
* ³ A new account (1, and 4 when it resumes account creation) lands on the Wallet tab, so a new
* user sees their balance. A restore (2) or an app resume (3) lands on Chats, where a cold launch
* opens. See [onboardingLandingRoute].
*/
@Composable
fun OnboardingFlowScreen(
Expand Down Expand Up @@ -159,8 +162,7 @@ private fun PermissionsPhaseFlowHost(
val userManager = LocalUserManager.current
val contactPickerMode by featureFlags.observe(FeatureFlag.ContactPickerMode).collectAsStateWithLifecycle()

// Onboarding releases to the same home the app launches on: the Wallet tab.
val home = homeRoute
val home = onboardingLandingRoute(route)

val permissionsSteps = buildList {
if (!route.skipContacts && !contactPickerMode) add(OnboardingStep.ContactPermission)
Expand Down Expand Up @@ -244,6 +246,14 @@ private fun AccountPhaseFlowHost(
)
}

/**
* Where the permissions phase releases to: the Wallet tab for an account onboarding just created,
* so a new user sees their balance, and the Chats tab a cold launch opens on for an account being
* signed in to.
*/
internal fun onboardingLandingRoute(route: AppRoute.OnboardingFlow): AppRoute =
if (route.newAccount) homeRoute else launchRoute

internal fun resolvePostAccountRoute(
result: OnboardingResult,
skipContacts: Boolean = true,
Expand All @@ -253,7 +263,7 @@ internal fun resolvePostAccountRoute(
skipContacts = skipContacts,
)
return when (result) {
is OnboardingResult.ProceedToVerification -> permissionsRoute
is OnboardingResult.ProceedToVerification -> permissionsRoute.copy(newAccount = true)
OnboardingResult.LoggedIn -> permissionsRoute
OnboardingResult.Completed -> null
}
Expand All @@ -278,6 +288,7 @@ private fun FlowNavigator<OnboardingStep, OnboardingResult>.proceedToNameOrPermi
target = AppRoute.OnboardingFlow(
phase = AppRoute.OnboardingFlow.Phase.Permissions,
skipContacts = true,
newAccount = true,
),
allowBack = false,
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package com.flipcash.app.login

import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.navigation.homeRoute
import com.flipcash.app.core.navigation.launchRoute
import com.flipcash.app.core.onboarding.OnboardingResult
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue

class OnboardingRoutingTest {

Expand All @@ -19,6 +23,7 @@ class OnboardingRoutingTest {
val flow = assertIs<AppRoute.OnboardingFlow>(route)
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase)
assertEquals(true, flow.skipContacts)
assertTrue(flow.newAccount)
}

// -- LoggedIn routes to permissions --
Expand All @@ -31,6 +36,7 @@ class OnboardingRoutingTest {
val flow = assertIs<AppRoute.OnboardingFlow>(route)
assertEquals(AppRoute.OnboardingFlow.Phase.Permissions, flow.phase)
assertEquals(true, flow.skipContacts)
assertFalse(flow.newAccount)
}

// -- skipContacts is forwarded --
Expand All @@ -52,4 +58,21 @@ class OnboardingRoutingTest {
fun `Completed returns null`() {
assertNull(resolvePostAccountRoute(OnboardingResult.Completed))
}

// -- Where onboarding lands --

@Test
fun `a new account lands on the wallet`() {
val route = AppRoute.OnboardingFlow(
phase = AppRoute.OnboardingFlow.Phase.Permissions,
newAccount = true,
)
assertEquals(homeRoute, onboardingLandingRoute(route))
}

@Test
fun `an existing account lands where a cold launch does`() {
val route = AppRoute.OnboardingFlow(phase = AppRoute.OnboardingFlow.Phase.Permissions)
assertEquals(launchRoute, onboardingLandingRoute(route))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.data.Loadable
import com.flipcash.libs.coroutines.DispatcherProvider
import com.flipcash.app.core.extensions.onResult
import com.flipcash.app.core.tipping.TipStep
import com.flipcash.app.shareable.ShareSheetController
Expand All @@ -14,12 +15,15 @@ import com.flipcash.features.tipping.R
import com.flipcash.services.models.chat.ChatType
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.chat.ChatSummary
import com.flipcash.shared.chat.ui.ConversationReference
import com.flipcash.shared.chat.ui.toConversationReference
import com.flipcash.shared.tipping.TippingCoordinator
import com.getcode.opencode.model.financial.Token
import com.getcode.util.resources.ResourceHelper
import com.getcode.view.BaseViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
Expand All @@ -37,6 +41,7 @@ internal class TipFlowViewModel @Inject constructor(
shareable: ShareSheetController,
tipCodePreviewCache: TipCodePreviewCache,
private val resources: ResourceHelper,
dispatchers: DispatcherProvider,
) : BaseViewModel<TipFlowViewModel.State, TipFlowViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -86,14 +91,29 @@ internal class TipFlowViewModel @Inject constructor(
})
.launchIn(viewModelScope)

fun conversations(summaries: List<ChatSummary>, tokens: List<Token>): List<ConversationReference> {
val selfId = userManager.accountId
val tokensByMint = tokens.associateBy { it.address }
return summaries.map { it.toConversationReference(selfId, tokensByMint, resources) }
}

// On a cold launch the feed is usually built before this screen is, so draw it on the first
// frame rather than waiting for the collector below to get a turn on the main thread.
chatCoordinator.currentFeed(ChatType.TIP_DM, ChatType.GROUP)?.let { summaries ->
dispatchEvent(
Event.ChatsUpdated(Loadable.Loaded(conversations(summaries, tokenCoordinator.cachedTokens())))
)
}

combine(
chatCoordinator.feed(ChatType.TIP_DM, ChatType.GROUP),
tokenCoordinator.tokens,
) { summaries, tokens ->
val selfId = userManager.accountId
val tokensByMint = tokens.associateBy { it.address }
summaries.map { it.toConversationReference(selfId, tokensByMint, resources) }
}.onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope)
::conversations,
)
// Off the main thread: on a cold launch the first mapping lands while the main thread
// is drawing the app's first screens, and waiting for it holds the Chats tab blank.
.flowOn(dispatchers.Default)
.onEach { dispatchEvent(Event.ChatsUpdated(Loadable.Loaded(it))) }.launchIn(viewModelScope)

eventFlow
.filterIsInstance<Event.ShareTipCard>()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@ interface FeedOperations {
*/
fun feed(vararg chatTypes: ChatType): Flow<List<ChatSummary>>

/**
* What [feed] would emit right now, or null while it would emit nothing yet. Lets a screen
* built after the feed is ready draw it on its first frame instead of waiting for a collector.
*/
fun currentFeed(vararg chatTypes: ChatType): List<ChatSummary>?

/** Emits the number of conversations of any of [chatTypes] that have unread messages. */
fun observeUnreadConversations(vararg chatTypes: ChatType): Flow<Int>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import com.getcode.opencode.model.core.ID
import kotlin.time.Instant

data class ChatState(
val feed: List<ChatMetadata> = emptyList(),
// Null until the feed has been read from the database once, so an empty list means "no
// chats" and never "not read yet".
val feed: List<ChatMetadata>? = null,
val typingIndicators: Map<ChatId, Set<ActiveTypist>> = emptyMap(),
val reactionOverlays: Map<ChatId, Map<Long, ReactionSummary>> = emptyMap(),
val feedSyncState: FeedSyncState = FeedSyncState.Idle,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,13 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeoutOrNull
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
Expand Down Expand Up @@ -100,6 +102,7 @@ class RealChatCoordinator @Inject constructor(

companion object {
private const val TAG = "ChatCoordinator"
private val FEED_READ_WAIT = 3.seconds
}

// Recreated on re-login: [reset] cancels [supervisorJob] on logout, which would
Expand Down Expand Up @@ -141,6 +144,11 @@ class RealChatCoordinator @Inject constructor(
// becoming retryable. See [MessagingDelegate.recoverInterruptedSends].
messagingDelegate.recoverInterruptedSends()
feedDelegate.observeFeedFromDb()
// The list the Chats tab opens on comes from this read, so let it finish before the sync
// below starts competing with it for CPU (a cold-launch trace had the sync's gRPC work
// on every core while the Room reads waited to run). Bounded, so a read that never
// answers delays the sync rather than cancelling it.
withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feed != null } }
syncFeeds()
eventStreamDelegate.open()
eventStreamDelegate.startHeartbeat { syncFeeds() }
Expand Down
Loading
Loading