Skip to content

Commit 52b5b8b

Browse files
authored
feat(v2): route tip-card & chats tabs through the tipping flow (#1224)
* refactor(ui): flush start-aligned AppBar titles AppBarWithTitle reserved a phantom leading slot (a back button's width) for every title so a centered one stays optically balanced against the end actions. For a start/end-aligned title that only indents it past a control that isn't there. Gate the reservation to centered titles, and probe the raw leading icon (the slot's 5dp padding otherwise makes an empty slot measure non-zero) so a start-aligned title with no leading control sits flush at the inset. Add a screenTitleLarge type token for large flush titles (e.g. the Chats tab). * feat(v2): route tip-card & chats tabs through the tipping flow Wire the two remaining v2 nav-bar tabs (were TODO/null) to the existing tipping flow rather than bespoke screens, so tab taps, deeplinks, and the tip-card decorator all land in one code path: - NavBarButton.TipCard -> AppRoute.Sheets.Tips(resumed = true) (flow at TipCard) - NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false) (flow at the list) - asNavBarTab maps Sheets.Tips back to the tab by its resumed flag. TippingFlowScreen renders safely as a full-screen tab: the sheet-dismiss dispatcher defaults to a no-op, so the flow's onExit is inert off a sheet. Collapse the v1/v2 screens into one public flag-branched screen each (TipCardScreen, TipsScreen), moved out of internal/screens. Both are always flow steps, so they always use the flow-shared TipFlowViewModel; only the chrome differs by FeatureFlag.NewUi: - TipCard: v2 = centered card + hamburger menu; v1 = title/back/share scaffold. - Tips: v2 = flush "Chats" large title, no close/button; v1 = "Tips" + close + a "Show Tip Card" button. The shared hamburger icon moves from the scanner module into core so both v1 and v2 resolve it. Adds :shared:featureflags to the tipping module. * fix(v2): seed the tip-card tab on TipCard synchronously The tip-card tab is the tipping flow at Sheets.Tips(resumed = true). The flow seeded its first step from the VM's state.steps, which defaults to [Tips] and only becomes [TipCard] after the async profile combine resolves — so a tab switch that crossfades into the tip-card tab briefly rendered the chats list. Seed [TipCard] straight from route.resumed instead, so the card shows from the first frame.
1 parent ea03e33 commit 52b5b8b

12 files changed

Lines changed: 407 additions & 285 deletions

File tree

‎apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarRoutes.kt‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import com.flipcash.app.core.AppRoute
1111
fun NavBarButton.destinationRoute(): AppRoute? = when (this) {
1212
NavBarButton.Scanner -> AppRoute.Main.Scanner
1313
NavBarButton.Wallet -> AppRoute.Sheets.Wallet
14-
NavBarButton.Chats -> null // TODO(v2): wire the chats destination
15-
NavBarButton.TipCard -> null // TODO(v2): wire the tip-card destination
14+
// Both tabs route through the tipping flow, seeded at the right step: the chats list vs the tip
15+
// card (resumed = true lands the flow on TipCard alone). Deeplinks/decorators use the same route.
16+
NavBarButton.Chats -> AppRoute.Sheets.Tips(resumed = false)
17+
NavBarButton.TipCard -> AppRoute.Sheets.Tips(resumed = true)
1618
// v1-only buttons never appear in the v2 bar.
1719
NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips -> null
1820
}
@@ -21,5 +23,7 @@ fun NavBarButton.destinationRoute(): AppRoute? = when (this) {
2123
fun AppRoute.asNavBarTab(): NavBarButton? = when (this) {
2224
AppRoute.Main.Scanner -> NavBarButton.Scanner
2325
AppRoute.Sheets.Wallet -> NavBarButton.Wallet
26+
// The tipping flow is home to both tabs; `resumed` distinguishes the tip card from the list.
27+
is AppRoute.Sheets.Tips -> if (resumed) NavBarButton.TipCard else NavBarButton.Chats
2428
else -> null
2529
}

apps/flipcash/features/scanner/src/main/res/drawable/ic_home_options.xml renamed to apps/flipcash/core/src/main/res/drawable/ic_home_options.xml

File renamed without changes.

‎apps/flipcash/core/src/main/res/values/strings.xml‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,6 +872,7 @@
872872

873873
<string name="action_tips">Tips</string>
874874
<string name="title_tips">Tips</string>
875+
<string name="title_chats">Chats</string>
875876
<string name="title_tipIntro">Receive Tips From Everyone</string>
876877
<string name="subtitle_tipIntro">Add your name to receive tips</string>
877878
<string name="action_startReceivingTips">Start Receiving Tips</string>

‎apps/flipcash/features/tipping/build.gradle.kts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
implementation(project(":libs:messaging"))
1313
implementation(project(":apps:flipcash:shared:amount-entry"))
1414
implementation(project(":apps:flipcash:shared:bills"))
15+
implementation(project(":apps:flipcash:shared:featureflags"))
1516
implementation(project(":apps:flipcash:shared:chat"))
1617
implementation(project(":apps:flipcash:shared:chat-ui"))
1718
implementation(project(":apps:flipcash:shared:shareable"))
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
package com.flipcash.app.tipping
2+
3+
import androidx.compose.foundation.Image
4+
import androidx.compose.foundation.layout.Arrangement
5+
import androidx.compose.foundation.layout.Box
6+
import androidx.compose.foundation.layout.Column
7+
import androidx.compose.foundation.layout.Row
8+
import androidx.compose.foundation.layout.fillMaxSize
9+
import androidx.compose.foundation.layout.fillMaxWidth
10+
import androidx.compose.foundation.layout.navigationBarsPadding
11+
import androidx.compose.foundation.layout.offset
12+
import androidx.compose.foundation.layout.padding
13+
import androidx.compose.foundation.layout.requiredSize
14+
import androidx.compose.foundation.layout.statusBarsPadding
15+
import androidx.compose.foundation.shape.CircleShape
16+
import androidx.compose.material3.Icon
17+
import androidx.compose.material3.Text
18+
import androidx.compose.runtime.Composable
19+
import androidx.compose.runtime.CompositionLocalProvider
20+
import androidx.compose.runtime.getValue
21+
import androidx.compose.runtime.remember
22+
import androidx.compose.ui.Alignment
23+
import androidx.compose.ui.Modifier
24+
import androidx.compose.ui.draw.clip
25+
import androidx.compose.ui.graphics.Color
26+
import androidx.compose.ui.res.painterResource
27+
import androidx.compose.ui.res.stringResource
28+
import androidx.compose.ui.unit.dp
29+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
30+
import com.flipcash.app.bills.ScannableRenderer
31+
import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha
32+
import com.flipcash.app.bills.components.cards.LocalTipCardColor
33+
import com.flipcash.app.core.AppRoute
34+
import com.flipcash.app.core.bill.Scannable
35+
import com.flipcash.app.core.extensions.openAsSheet
36+
import com.flipcash.app.core.tipping.TipResult
37+
import com.flipcash.app.core.tipping.TipStep
38+
import com.flipcash.app.featureflags.FeatureFlag
39+
import com.flipcash.app.featureflags.LocalFeatureFlags
40+
import com.flipcash.app.tipping.internal.TipFlowViewModel
41+
import com.flipcash.app.tipping.internal.TipFlowViewModel.Event
42+
import com.flipcash.features.tipping.R
43+
import com.getcode.navigation.core.LocalCodeNavigator
44+
import com.getcode.navigation.flow.flowSharedViewModel
45+
import com.getcode.navigation.flow.rememberFlowNavigator
46+
import com.getcode.theme.CodeTheme
47+
import com.getcode.ui.components.AppBarWithTitle
48+
import com.getcode.ui.components.CircularIconButton
49+
import com.getcode.ui.core.unboundedClickable
50+
import com.getcode.ui.theme.CodeScaffold
51+
52+
/**
53+
* The user's own tip card — always a step in the tipping [TippingFlowScreen] flow, so it shares the
54+
* flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]:
55+
* - **v2**: a root tab (the flow seeded at TipCard) — the card centered, with the app menu reachable
56+
* via the hamburger in the top-right.
57+
* - **v1**: a sheet step — title bar with back, and a Share action.
58+
*/
59+
@Composable
60+
fun TipCardScreen() {
61+
val features = LocalFeatureFlags.current
62+
val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value }
63+
64+
val viewModel = flowSharedViewModel<TipFlowViewModel>()
65+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
66+
67+
if (isNewUi) {
68+
val navigator = LocalCodeNavigator.current
69+
Box(modifier = Modifier.fillMaxSize()) {
70+
TipCardArt(card = state.tipCard, modifier = Modifier.fillMaxSize())
71+
72+
Image(
73+
painter = painterResource(R.drawable.ic_home_options),
74+
contentDescription = null,
75+
modifier = Modifier
76+
.align(Alignment.TopEnd)
77+
.statusBarsPadding()
78+
.padding(vertical = CodeTheme.dimens.grid.x2)
79+
.padding(horizontal = CodeTheme.dimens.grid.x3)
80+
.clip(CircleShape)
81+
.unboundedClickable { navigator.openAsSheet(AppRoute.Sheets.Menu) },
82+
)
83+
}
84+
} else {
85+
val flowNavigator = rememberFlowNavigator<TipStep, TipResult>()
86+
CodeScaffold(
87+
topBar = {
88+
Column(
89+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x10),
90+
horizontalAlignment = Alignment.CenterHorizontally,
91+
) {
92+
AppBarWithTitle(
93+
title = stringResource(R.string.title_myTipCard),
94+
titleAlignment = Alignment.CenterHorizontally,
95+
onBackIconClicked = { flowNavigator.back() },
96+
)
97+
Text(
98+
text = stringResource(R.string.subtitle_myTipCard),
99+
style = CodeTheme.typography.textLarge,
100+
color = CodeTheme.colors.textMain,
101+
)
102+
}
103+
},
104+
bottomBar = {
105+
Row(
106+
modifier = Modifier.fillMaxWidth()
107+
.navigationBarsPadding()
108+
.padding(bottom = CodeTheme.dimens.grid.x3),
109+
horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x5, Alignment.CenterHorizontally),
110+
verticalAlignment = Alignment.CenterVertically,
111+
) {
112+
Column(
113+
modifier = Modifier.padding(bottom = CodeTheme.dimens.grid.x3),
114+
horizontalAlignment = Alignment.CenterHorizontally,
115+
verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3),
116+
) {
117+
CircularIconButton(
118+
imageSize = CodeTheme.dimens.staticGrid.x6,
119+
buttonSize = CodeTheme.dimens.grid.x12,
120+
onClick = {
121+
viewModel.dispatchEvent(Event.ShareTipCard)
122+
}
123+
) { size ->
124+
Icon(
125+
painter = painterResource(R.drawable.ic_remote_send),
126+
contentDescription = null,
127+
tint = Color.White,
128+
// The send glyph is bottom-heavy (wide tray below a thin arrow),
129+
// so geometric centering leaves it sitting visually low in the
130+
// circle. Nudge it up slightly to optically center it.
131+
modifier = Modifier
132+
.requiredSize(size)
133+
.offset(y = (-1.5).dp),
134+
)
135+
}
136+
Text(
137+
text = stringResource(R.string.action_share),
138+
style = CodeTheme.typography.textSmall,
139+
color = CodeTheme.colors.textSecondary,
140+
)
141+
}
142+
}
143+
}
144+
) { padding ->
145+
Column(
146+
modifier = Modifier
147+
.fillMaxSize()
148+
.padding(padding),
149+
horizontalAlignment = Alignment.CenterHorizontally,
150+
verticalArrangement = Arrangement.Center
151+
) {
152+
TipCardArt(card = state.tipCard)
153+
}
154+
}
155+
}
156+
}
157+
158+
@Composable
159+
private fun TipCardArt(card: Scannable.TipCard?, modifier: Modifier = Modifier) {
160+
if (card == null) return
161+
CompositionLocalProvider(
162+
// Static display (no camera behind the card), so render it opaque at the design's flattened
163+
// colour rather than the translucent frosted fill — the 36% alpha otherwise multiplies the
164+
// app's Brand background and reads incorrectly. Figma flattens the card to rgb(16,16,17).
165+
LocalTipCardColor provides Color(0xFF101011),
166+
LocalTipCardBaseAlpha provides 1f,
167+
) {
168+
Box(modifier = modifier, contentAlignment = Alignment.Center) {
169+
ScannableRenderer(scannable = card)
170+
}
171+
}
172+
}

‎apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,7 @@ import com.flipcash.app.core.AppRoute
1212
import com.flipcash.app.core.tipping.TipResult
1313
import com.flipcash.app.core.tipping.TipStep
1414
import com.flipcash.app.tipping.internal.TipFlowViewModel
15-
import com.flipcash.app.tipping.internal.screens.TipCardScreen
1615
import com.flipcash.app.tipping.internal.screens.TipInfoScreen
17-
import com.flipcash.app.tipping.internal.screens.TipsScreen
1816
import com.getcode.navigation.annotatedEntry
1917
import com.getcode.navigation.flow.FlowExitReason
2018
import com.getcode.navigation.flow.FlowHost
@@ -38,8 +36,13 @@ fun TippingFlowScreen(
3836
viewModel.dispatchEvent(TipFlowViewModel.Event.OnResumed(route.resumed))
3937
}
4038

39+
// `resumed` is deterministically the tip card, so seed it synchronously rather than waiting for
40+
// the VM's async steps to resolve. Otherwise the tip-card tab renders the default first step
41+
// (the chats list) for a frame — briefly visible when a tab switch crossfades into it.
42+
val steps = if (route.resumed) listOf(TipStep.TipCard) else state.steps
43+
4144
FlowHost<TipStep, TipResult>(
42-
steps = state.steps,
45+
steps = steps,
4346
resumeAt = 0,
4447
resultStateRegistry = resultStateRegistry,
4548
// Proceeding off the end (TipCard's Done, or backing out at root) completes the flow.
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
package com.flipcash.app.tipping
2+
3+
import androidx.compose.foundation.background
4+
import androidx.compose.foundation.layout.Box
5+
import androidx.compose.foundation.layout.fillMaxSize
6+
import androidx.compose.foundation.layout.fillMaxWidth
7+
import androidx.compose.foundation.layout.padding
8+
import androidx.compose.foundation.lazy.LazyColumn
9+
import androidx.compose.foundation.lazy.LazyListScope
10+
import androidx.compose.foundation.lazy.itemsIndexed
11+
import androidx.compose.runtime.Composable
12+
import androidx.compose.runtime.getValue
13+
import androidx.compose.runtime.remember
14+
import androidx.compose.ui.Alignment
15+
import androidx.compose.ui.Modifier
16+
import androidx.compose.ui.res.stringResource
17+
import androidx.lifecycle.compose.collectAsStateWithLifecycle
18+
import com.flipcash.app.core.AppRoute
19+
import com.flipcash.app.core.chat.ChatIdentifier
20+
import com.flipcash.app.core.tipping.TipStep
21+
import com.flipcash.app.featureflags.FeatureFlag
22+
import com.flipcash.app.featureflags.LocalFeatureFlags
23+
import com.flipcash.app.tipping.internal.TipFlowViewModel
24+
import com.flipcash.app.tipping.internal.components.TipChatRow
25+
import com.flipcash.features.tipping.R
26+
import com.flipcash.shared.chat.ui.ConversationReference
27+
import com.getcode.navigation.core.LocalCodeNavigator
28+
import com.getcode.navigation.flow.flowSharedViewModel
29+
import com.getcode.theme.CodeTheme
30+
import com.getcode.ui.components.AppBarDefaults
31+
import com.getcode.ui.components.AppBarWithTitle
32+
import com.getcode.ui.theme.ButtonState
33+
import com.getcode.ui.theme.CodeButton
34+
import com.getcode.ui.theme.CodeScaffold
35+
36+
/**
37+
* The tip DM conversation list — always a step in the tipping [TippingFlowScreen] flow, so it shares
38+
* the flow's [TipFlowViewModel]. Only the chrome differs by [FeatureFlag.NewUi]:
39+
* - **v2**: the "Chats" root tab — a flush large title over the list, no dismiss affordance (the
40+
* root nav bar is the chrome); the tip card lives on its own tab.
41+
* - **v1**: the "Tips" sheet step — centered title with a Close, and a sticky "Show Tip Card" button
42+
* that advances the flow.
43+
*/
44+
@Composable
45+
fun TipsScreen() {
46+
val features = LocalFeatureFlags.current
47+
val isNewUi = remember(features) { features.observe(FeatureFlag.NewUi).value }
48+
49+
val viewModel = flowSharedViewModel<TipFlowViewModel>()
50+
val state by viewModel.stateFlow.collectAsStateWithLifecycle()
51+
val navigator = LocalCodeNavigator.current
52+
53+
CodeScaffold(
54+
topBar = {
55+
if (isNewUi) {
56+
AppBarWithTitle(
57+
title = stringResource(R.string.title_chats),
58+
titleTextStyle = CodeTheme.typography.screenTitleLarge,
59+
)
60+
} else {
61+
AppBarWithTitle(
62+
title = stringResource(R.string.title_tips),
63+
titleAlignment = Alignment.CenterHorizontally,
64+
endContent = {
65+
AppBarDefaults.Close { navigator.hide() }
66+
}
67+
)
68+
}
69+
}
70+
) { padding ->
71+
LazyColumn(
72+
modifier = Modifier
73+
.fillMaxSize()
74+
.padding(padding),
75+
) {
76+
// v1 surfaces the tip card via a button here; v2 has a dedicated tip-card tab instead.
77+
if (!isNewUi) {
78+
stickyHeader {
79+
Box(
80+
modifier = Modifier
81+
.fillMaxWidth()
82+
.padding(vertical = CodeTheme.dimens.inset)
83+
.background(CodeTheme.colors.background),
84+
contentAlignment = Alignment.Center
85+
) {
86+
CodeButton(
87+
modifier = Modifier.fillMaxWidth()
88+
.padding(horizontal = CodeTheme.dimens.inset),
89+
text = stringResource(R.string.action_showTipCard),
90+
buttonState = ButtonState.Filled,
91+
onClick = {
92+
navigator.navigate(TipStep.TipCard)
93+
},
94+
)
95+
}
96+
}
97+
}
98+
99+
tipChatItems(state.tipChats) { chat ->
100+
navigator.push(AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(chat.chatId)))
101+
}
102+
}
103+
}
104+
}
105+
106+
private fun LazyListScope.tipChatItems(
107+
chats: List<ConversationReference>,
108+
onClick: (ConversationReference) -> Unit,
109+
) {
110+
itemsIndexed(chats) { index, chat ->
111+
TipChatRow(
112+
chat = chat,
113+
showDivider = index < chats.lastIndex,
114+
) {
115+
onClick(chat)
116+
}
117+
}
118+
}

0 commit comments

Comments
 (0)