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 @@ -11,8 +11,10 @@ import com.flipcash.app.core.AppRoute
fun NavBarButton.destinationRoute(): AppRoute? = when (this) {
NavBarButton.Scanner -> AppRoute.Main.Scanner
NavBarButton.Wallet -> AppRoute.Sheets.Wallet
NavBarButton.Chats -> null // TODO(v2): wire the chats destination
NavBarButton.TipCard -> null // TODO(v2): wire the tip-card destination
// Both tabs route through the tipping flow, seeded at the right step: the chats list vs the tip
// card (resumed = true lands the flow on TipCard alone). Deeplinks/decorators use the same route.
NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false)
NavBarButton.TipCard -> AppRoute.Sheets.Tips(resumed = true)
// v1-only buttons never appear in the v2 bar.
NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips -> null
}
Expand All @@ -21,5 +23,7 @@ fun NavBarButton.destinationRoute(): AppRoute? = when (this) {
fun AppRoute.asNavBarTab(): NavBarButton? = when (this) {
AppRoute.Main.Scanner -> NavBarButton.Scanner
AppRoute.Sheets.Wallet -> NavBarButton.Wallet
// The tipping flow is home to both tabs; `resumed` distinguishes the tip card from the list.
is AppRoute.Sheets.Tips -> if (resumed) NavBarButton.TipCard else NavBarButton.Chats
else -> null
}
1 change: 1 addition & 0 deletions apps/flipcash/core/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,7 @@

<string name="action_tips">Tips</string>
<string name="title_tips">Tips</string>
<string name="title_chats">Chats</string>
<string name="title_tipIntro">Receive Tips From Everyone</string>
<string name="subtitle_tipIntro">Add your name to receive tips</string>
<string name="action_startReceivingTips">Start Receiving Tips</string>
Expand Down
1 change: 1 addition & 0 deletions apps/flipcash/features/tipping/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {
implementation(project(":libs:messaging"))
implementation(project(":apps:flipcash:shared:amount-entry"))
implementation(project(":apps:flipcash:shared:bills"))
implementation(project(":apps:flipcash:shared:featureflags"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:chat-ui"))
implementation(project(":apps:flipcash:shared:shareable"))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
package com.flipcash.app.tipping

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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
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.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.bills.ScannableRenderer
import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha
import com.flipcash.app.bills.components.cards.LocalTipCardColor
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.extensions.openAsSheet
import com.flipcash.app.core.tipping.TipResult
import com.flipcash.app.core.tipping.TipStep
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.LocalFeatureFlags
import com.flipcash.app.tipping.internal.TipFlowViewModel
import com.flipcash.app.tipping.internal.TipFlowViewModel.Event
import com.flipcash.features.tipping.R
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.navigation.flow.rememberFlowNavigator
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.components.CircularIconButton
import com.getcode.ui.core.unboundedClickable
import com.getcode.ui.theme.CodeScaffold

/**
* The user's own tip card — always a step in the tipping [TippingFlowScreen] flow, so it shares the
* flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]:
* - **v2**: a root tab (the flow seeded at TipCard) — the card centered, with the app menu reachable
* via the hamburger in the top-right.
* - **v1**: a sheet step — title bar with back, and a Share action.
*/
@Composable
fun TipCardScreen() {
val features = LocalFeatureFlags.current
val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value }

val viewModel = flowSharedViewModel<TipFlowViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

if (isNewUi) {
val navigator = LocalCodeNavigator.current
Box(modifier = Modifier.fillMaxSize()) {
TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize())

Image(
painter = painterResource(R.drawable.ic_home_options),
contentDescription = null,
modifier = Modifier
.align(Alignment.TopEnd)
.statusBarsPadding()
.padding(vertical = CodeTheme.dimens.grid.x2)
.padding(horizontal = CodeTheme.dimens.grid.x3)
.clip(CircleShape)
.unboundedClickable { navigator.openAsSheet(AppRoute.Sheets.Menu) },
)
}
} else {
val flowNavigator = rememberFlowNavigator<TipStep, TipResult>()
CodeScaffold(
topBar = {
Column(
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x10),
horizontalAlignment = Alignment.CenterHorizontally,
) {
AppBarWithTitle(
title = stringResource(R.string.title_myTipCard),
titleAlignment = Alignment.CenterHorizontally,
onBackIconClicked = { flowNavigator.back() },
)
Text(
text = stringResource(R.string.subtitle_myTipCard),
style = CodeTheme.typography.textLarge,
color = CodeTheme.colors.textMain,
)
}
},
bottomBar = {
Row(
modifier = Modifier.fillMaxWidth()
.navigationBarsPadding()
.padding(bottom = CodeTheme.dimens.grid.x3),
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5, Alignment.CenterHorizontally),
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
) {
CircularIconButton(
imageSize = CodeTheme.dimens.staticGrid.x6,
buttonSize = CodeTheme.dimens.grid.x12,
onClick = {
viewModel.dispatchEvent(Event.ShareTipCard)
}
) { size ->
Icon(
painter = painterResource(R.drawable.ic_remote_send),
contentDescription = null,
tint = Color.White,
// The send glyph is bottom-heavy (wide tray below a thin arrow),
// so geometric centering leaves it sitting visually low in the
// circle. Nudge it up slightly to optically center it.
modifier = Modifier
.requiredSize(size)
.offset(y = (-1.5).dp),
)
}
Text(
text = stringResource(R.string.action_share),
style = CodeTheme.typography.textSmall,
color = CodeTheme.colors.textSecondary,
)
}
}
}
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
TipCardArt(card = state.tipCard)
}
}
}
}

@Composable
private fun TipCardArt(card: Scannable.TipCard?, modifier: Modifier = Modifier) {
if (card == null) return
CompositionLocalProvider(
// Static display (no camera behind the card), so render it opaque at the design's flattened
// colour rather than the translucent frosted fill — the 36% alpha otherwise multiplies the
// app's Brand background and reads incorrectly. Figma flattens the card to rgb(16,16,17).
LocalTipCardColor provides Color(0xFF101011),
LocalTipCardBaseAlpha provides 1f,
) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
ScannableRenderer(scannable = card)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,7 @@ import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.tipping.TipResult
import com.flipcash.app.core.tipping.TipStep
import com.flipcash.app.tipping.internal.TipFlowViewModel
import com.flipcash.app.tipping.internal.screens.TipCardScreen
import com.flipcash.app.tipping.internal.screens.TipInfoScreen
import com.flipcash.app.tipping.internal.screens.TipsScreen
import com.getcode.navigation.annotatedEntry
import com.getcode.navigation.flow.FlowExitReason
import com.getcode.navigation.flow.FlowHost
Expand All @@ -38,8 +36,13 @@ fun TippingFlowScreen(
viewModel.dispatchEvent(TipFlowViewModel.Event.OnResumed(route.resumed))
}

// `resumed` is deterministically the tip card, so seed it synchronously rather than waiting for
// the VM's async steps to resolve. Otherwise the tip-card tab renders the default first step
// (the chats list) for a frame — briefly visible when a tab switch crossfades into it.
val steps = if (route.resumed) listOf(TipStep.TipCard) else state.steps

FlowHost<TipStep, TipResult>(
steps = state.steps,
steps = steps,
resumeAt = 0,
resultStateRegistry = resultStateRegistry,
// Proceeding off the end (TipCard's Done, or backing out at root) completes the flow.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.flipcash.app.tipping

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.itemsIndexed
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.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.flipcash.app.core.AppRoute
import com.flipcash.app.core.chat.ChatIdentifier
import com.flipcash.app.core.tipping.TipStep
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.LocalFeatureFlags
import com.flipcash.app.tipping.internal.TipFlowViewModel
import com.flipcash.app.tipping.internal.components.TipChatRow
import com.flipcash.features.tipping.R
import com.flipcash.shared.chat.ui.ConversationReference
import com.getcode.navigation.core.LocalCodeNavigator
import com.getcode.navigation.flow.flowSharedViewModel
import com.getcode.theme.CodeTheme
import com.getcode.ui.components.AppBarDefaults
import com.getcode.ui.components.AppBarWithTitle
import com.getcode.ui.theme.ButtonState
import com.getcode.ui.theme.CodeButton
import com.getcode.ui.theme.CodeScaffold

/**
* The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares
* the flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]:
* - **v2**: the "Chats" root tab — a flush large title over the list, no dismiss affordance (the
* root nav bar is the chrome); the tip card lives on its own tab.
* - **v1**: the "Tips" sheet step — centered title with a Close, and a sticky "Show Tip Card" button
* that advances the flow.
*/
@Composable
fun TipsScreen() {
val features = LocalFeatureFlags.current
val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value }

val viewModel = flowSharedViewModel<TipFlowViewModel>()
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
val navigator = LocalCodeNavigator.current

CodeScaffold(
topBar = {
if (isNewUi) {
AppBarWithTitle(
title = stringResource(R.string.title_chats),
titleTextStyle = CodeTheme.typography.screenTitleLarge,
)
} else {
AppBarWithTitle(
title = stringResource(R.string.title_tips),
titleAlignment = Alignment.CenterHorizontally,
endContent = {
AppBarDefaults.Close { navigator.hide() }
}
)
}
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
) {
// v1 surfaces the tip card via a button here; v2 has a dedicated tip-card tab instead.
if (!isNewUi) {
stickyHeader {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = CodeTheme.dimens.inset)
.background(CodeTheme.colors.background),
contentAlignment = Alignment.Center
) {
CodeButton(
modifier = Modifier.fillMaxWidth()
.padding(horizontal = CodeTheme.dimens.inset),
text = stringResource(R.string.action_showTipCard),
buttonState = ButtonState.Filled,
onClick = {
navigator.navigate(TipStep.TipCard)
},
)
}
}
}

tipChatItems(state.tipChats) { chat ->
navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)))
}
}
}
}

private fun LazyListScope.tipChatItems(
chats: List<ConversationReference>,
onClick: (ConversationReference) -> Unit,
) {
itemsIndexed(chats) { index, chat ->
TipChatRow(
chat = chat,
showDivider = index < chats.lastIndex,
) {
onClick(chat)
}
}
}
Loading
Loading