diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index d444a4bec..2fac50864 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -18,8 +18,11 @@ import com.flipcash.app.core.withdrawal.WithdrawalStep import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.onboarding.OnboardingStep import com.flipcash.app.core.tokens.FundingSource +import com.flipcash.app.core.ui.flow.SteppedFlowRoute import com.getcode.navigation.flow.FlowRoute import com.getcode.navigation.flow.FlowRouteWithResult +import com.getcode.navigation.flow.FlowStep +import kotlin.reflect.KClass import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.ui.core.RestrictionType @@ -208,9 +211,18 @@ sealed interface AppRoute : NavKey, Parcelable { data object Discovery: AppRoute @Serializable - data object CurrencyCreator : Token, FlowRouteWithResult { + data object CurrencyCreator : Token, SteppedFlowRoute { override val initialStack: List get() = listOf(CurrencyCreatorStep.Info) + + override val progressSteps: List> + get() = listOf( + CurrencyCreatorStep.NameSelection::class, + CurrencyCreatorStep.IconSelection::class, + CurrencyCreatorStep.DescriptionSelection::class, + CurrencyCreatorStep.BillCustomization::class, + CurrencyCreatorStep.BillReview::class, + ) } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowProgress.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowProgress.kt new file mode 100644 index 000000000..4734d0261 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowProgress.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.core.ui.flow + +import com.getcode.navigation.flow.FlowStep +import kotlin.reflect.KClass + +/** + * Fraction (0f..1f) of the stepped flow completed at [step], given the ordered [progressSteps] + * that participate in the progress indicator. Steps outside [progressSteps] (intro, funding, + * processing, etc.) report 0f so the bar hides. Extracted from CurrencyCreatorViewModel.progress + * so every stepped flow shares one definition. + */ +fun flowProgressFor( + step: FlowStep?, + progressSteps: List>, +): Float { + if (step == null || progressSteps.isEmpty()) return 0f + val index = progressSteps.indexOfFirst { it.isInstance(step) } + if (index < 0) return 0f + return (index + 1).toFloat() / progressSteps.size +} diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBar.kt similarity index 87% rename from apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBar.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBar.kt index aeb24273e..f01f43116 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBar.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.currencycreator.internal.components +package com.flipcash.app.core.ui.flow import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.layout.Box @@ -21,10 +21,15 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.utils.rememberKeyboardController +/** + * Top bar for a [SteppedFlowScaffold]. Shows a centered [LinearProgressIndicator] in the title slot + * while [FlowStepBarController.progress] is strictly between 0 and 1, unless [mainContent] overrides + * the title (used by steps like intro / processing that show a title instead of progress). + */ @OptIn(ExperimentalLayoutApi::class) @Composable -internal fun CurrencyCreatorTopBar( - controller: CurrencyCreatorTopBarController, +fun FlowStepBar( + controller: FlowStepBarController, mainContent: (@Composable () -> Unit)? = null, endContent: (@Composable () -> Unit)? = null, ) { diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBarController.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBarController.kt similarity index 52% rename from apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBarController.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBarController.kt index 3f7920e22..386471e44 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/CurrencyCreatorTopBarController.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepBarController.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.currencycreator.internal.components +package com.flipcash.app.core.ui.flow import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf @@ -6,14 +6,16 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf -internal class CurrencyCreatorTopBarController { +/** + * Mutable chrome state for a [SteppedFlowScaffold]'s top bar. The scaffold updates [progress]; + * each step wires [onBack] / [onEndAction] via the composition local below. + */ +class FlowStepBarController { var progress: Float by mutableFloatStateOf(0f) var onBack: (() -> Unit)? by mutableStateOf(null) var onEndAction: (() -> Unit)? by mutableStateOf(null) +} - companion object { - val LocalCurrencyCreatorTopBar = staticCompositionLocalOf { - error("No CurrencyCreatorTopBarController provided") - } - } +val LocalFlowStepBar = staticCompositionLocalOf { + error("No FlowStepBarController provided") } diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/FlowStepper.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepper.kt similarity index 54% rename from apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/FlowStepper.kt rename to apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepper.kt index 2b9ecdfc9..d59e5be96 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/components/FlowStepper.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/FlowStepper.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.currencycreator.internal.components +package com.flipcash.app.core.ui.flow import androidx.compose.foundation.background import androidx.compose.foundation.border @@ -19,89 +19,48 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.painter.Painter -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.flipcash.core.R -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.minus import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall -@Composable -internal fun Stepper(modifier: Modifier = Modifier, cost: Fiat, fee: Fiat?) { - val receiving = cost - (fee ?: Fiat.Zero) - val isReceivingAmount = receiving > Fiat.Zero +/** + * One entry in a [FlowStepper]. [weight] > 0 makes the row expand and draws a gradient connector to + * the next item when [showConnector] is true. + */ +data class StepperItem( + val icon: Painter, + val title: String, + val description: String, + val weight: Float = 0.6f, + val showConnector: Boolean = weight > 0f, +) +/** Vertical wizard stepper. Content is fully caller-supplied via [items]. */ +@Composable +fun FlowStepper( + items: List, + modifier: Modifier = Modifier, +) { Column(modifier = modifier) { - StepperItem( - icon = painterResource(R.drawable.ic_currencycreator_name), - title = stringResource(R.string.title_currencyCreatorStepName), - description = stringResource(R.string.subtitle_currencyCreatorStepName), - weight = 0.6f, - ) - StepperItem( - icon = painterResource(R.drawable.ic_currencycreator_icon), - title = stringResource(R.string.title_currencyCreatorStepIcon), - description = stringResource(R.string.subtitle_currencyCreatorStepIcon), - weight = 0.6f, - ) - StepperItem( - painterResource(R.drawable.ic_currencycreator_description), - title = stringResource(R.string.title_currencyCreatorStepDescription), - description = stringResource(R.string.subtitle_currencyCreatorStepDescription), - weight = 0.6f, - ) - StepperItem( - painterResource(R.drawable.ic_currencycreator_cashbill), - title = stringResource(R.string.title_currencyCreatorStepDesign), - description = stringResource(R.string.subtitle_currencyCreatorStepDesign), - weight = 0.6f, - ) - StepperItem( - painterResource(R.drawable.ic_currencycreator_purchase), - title = stringResource( - R.string.title_currencyCreatorStepPurchase, - cost.formatted( - rule = Fiat.FormattingRule.Truncated, - suffix = stringResource(R.string.subtitle_usdSuffix) - ) - ), - description = stringResource(R.string.subtitle_currencyCreatorStepPurchase), - weight = 0.6f, - showConnector = isReceivingAmount, - ) - - if (isReceivingAmount) { - StepperItem( - painterResource(R.drawable.ic_currencycreator_gift), - title = stringResource( - R.string.title_currencyCreatorStepPurchaseFreeGift, - receiving.formatted(rule = Fiat.FormattingRule.Truncated) - ), - description = stringResource( - R.string.subtitle_currencyCreatorStepPurchaseFreeGift, - receiving.formatted(rule = Fiat.FormattingRule.Truncated) - ), - weight = 0.6f, - showConnector = false + items.forEach { item -> + StepperRow( + icon = item.icon, + title = item.title, + description = item.description, + weight = item.weight, + showConnector = item.showConnector, ) } } } -/** - * A single step in the vertical stepper. When [weight] > 0 the item expands - * proportionally and a gradient connector line fills the space between the - * icon box and the next item. The last item should pass [weight] = 0. - */ @Composable -private fun ColumnScope.StepperItem( +private fun ColumnScope.StepperRow( icon: Painter, title: String, description: String, weight: Float, - showConnector: Boolean = weight > 0f + showConnector: Boolean, ) { val hasConnector = weight > 0f Row( @@ -174,4 +133,4 @@ private fun ColumnScope.StepperItem( ) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowRoute.kt new file mode 100644 index 000000000..9de67d591 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowRoute.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.core.ui.flow + +import android.os.Parcelable +import com.getcode.navigation.flow.FlowRouteWithResult +import com.getcode.navigation.flow.FlowStep +import kotlin.reflect.KClass + +/** + * A [FlowRouteWithResult] whose flow presents a stepped-progress wizard. Declaring a route as a + * [SteppedFlowRoute] is how a flow opts into the shared [SteppedFlowScaffold] chrome (progress top + * bar). [progressSteps] is the ordered list of step types that fill the progress bar; steps not in + * this list (intro / processing / funding, etc.) show no progress. + */ +interface SteppedFlowRoute : FlowRouteWithResult { + val progressSteps: List> +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowScaffold.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowScaffold.kt new file mode 100644 index 000000000..f54f3f945 --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/flow/SteppedFlowScaffold.kt @@ -0,0 +1,55 @@ +package com.flipcash.app.core.ui.flow + +import android.os.Parcelable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import com.getcode.navigation.flow.FlowExitReason +import com.getcode.navigation.flow.FlowHost +import com.getcode.navigation.flow.FlowStep +import com.getcode.navigation.results.NavResultStateRegistry + +/** + * Renders the shared stepped-progress chrome above a [FlowHost]. Owns the progress computation + * (from [SteppedFlowRoute.progressSteps] and the caller-provided [currentStep]) so consumers no + * longer track progress in their ViewModel. Per-step [titleContent] / [endContent] are supplied by + * the consumer (they read their own state); each step wires back / end-action through + * [LocalFlowStepBar]. + * + * @param currentStep the step currently shown, typically read from the shared flow ViewModel's state. + */ +@Composable +fun SteppedFlowScaffold( + route: SteppedFlowRoute, + initialStack: List, + currentStep: FlowStep?, + resultStateRegistry: NavResultStateRegistry, + onExit: (reason: FlowExitReason, isSheetRoot: Boolean) -> Unit, + titleContent: (@Composable () -> Unit)? = null, + endContent: (@Composable () -> Unit)? = null, + entryProvider: (NavKey) -> NavEntry, +) { + val controller = remember { FlowStepBarController() } + controller.progress = flowProgressFor(currentStep, route.progressSteps) + + CompositionLocalProvider(LocalFlowStepBar provides controller) { + Column(modifier = Modifier.fillMaxSize()) { + FlowStepBar( + controller = controller, + mainContent = titleContent, + endContent = endContent, + ) + FlowHost( + initialStack = initialStack, + resultStateRegistry = resultStateRegistry, + onExit = onExit, + entryProvider = entryProvider, + ) + } + } +} diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/ui/flow/FlowProgressTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/ui/flow/FlowProgressTest.kt new file mode 100644 index 000000000..4b2bc5706 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/ui/flow/FlowProgressTest.kt @@ -0,0 +1,40 @@ +package com.flipcash.app.core.ui.flow + +import com.getcode.navigation.flow.FlowStep +import org.junit.Assert.assertEquals +import org.junit.Test + +class FlowProgressTest { + + private object StepA : FlowStep + private object StepB : FlowStep + private object StepC : FlowStep + private object OffListStep : FlowStep + + private val progressSteps = listOf(StepA::class, StepB::class, StepC::class) + + @Test + fun `null step is zero progress`() { + assertEquals(0f, flowProgressFor(null, progressSteps)) + } + + @Test + fun `empty progress steps is zero progress`() { + assertEquals(0f, flowProgressFor(StepA, emptyList())) + } + + @Test + fun `step not in list is zero progress`() { + assertEquals(0f, flowProgressFor(OffListStep, progressSteps)) + } + + @Test + fun `first step is one third`() { + assertEquals(1f / 3f, flowProgressFor(StepA, progressSteps)) + } + + @Test + fun `last step is full`() { + assertEquals(1f, flowProgressFor(StepC, progressSteps)) + } +} diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt index 4f8154abd..bb159132e 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/CurrencyCreatorFlowScreen.kt @@ -1,8 +1,6 @@ package com.flipcash.app.currencycreator import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Text @@ -12,16 +10,11 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.getcode.navigation.flow.flowSharedViewModel -import com.getcode.navigation.flow.rememberInitialStack -import com.getcode.navigation.flow.rememberFlowNavigator import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -29,14 +22,16 @@ import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep -import com.flipcash.app.currencycreator.internal.screens.BillCustomizationContent -import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBar -import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController -import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController.Companion.LocalCurrencyCreatorTopBar +import com.flipcash.app.core.ui.flow.FlowStepBarController +import com.flipcash.app.core.ui.flow.LocalFlowStepBar +import com.flipcash.app.core.ui.flow.SteppedFlowScaffold import com.flipcash.app.currencycreator.internal.CurrencyCreatorViewModel +import com.flipcash.app.currencycreator.internal.screens.BillCustomizationContent import com.flipcash.app.currencycreator.internal.screens.BillCustomizationScreen +import com.flipcash.app.currencycreator.internal.screens.BillReviewScreen import com.flipcash.app.currencycreator.internal.screens.DescriptionSelectionContent import com.flipcash.app.currencycreator.internal.screens.DescriptionSelectionScreen +import com.flipcash.app.currencycreator.internal.screens.FundingSourceSelectionScreen import com.flipcash.app.currencycreator.internal.screens.IconSelectionContent import com.flipcash.app.currencycreator.internal.screens.IconSelectionScreen import com.flipcash.app.currencycreator.internal.screens.InfoScreen @@ -46,18 +41,18 @@ import com.flipcash.app.currencycreator.internal.screens.NameSelectionScreen import com.flipcash.app.currencycreator.internal.screens.ProcessingContent import com.flipcash.app.currencycreator.internal.screens.ProcessingScreen import com.flipcash.app.currencycreator.internal.screens.ReviewContent -import com.flipcash.app.currencycreator.internal.screens.BillReviewScreen -import com.flipcash.app.currencycreator.internal.screens.FundingSourceSelectionScreen import com.flipcash.app.session.LocalSessionController import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.navigation.annotatedEntry import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.flow.FlowExitReason -import com.getcode.navigation.flow.FlowHost import com.getcode.navigation.flow.LocalFlowNavigator import com.getcode.navigation.flow.PreviewFlowNavigator import com.getcode.navigation.flow.deliverFlowResult +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.flow.rememberFlowNavigator +import com.getcode.navigation.flow.rememberInitialStack import com.getcode.navigation.results.NavResultOrCanceled import com.getcode.navigation.results.NavResultStateRegistry import com.getcode.opencode.model.financial.Fiat @@ -75,163 +70,171 @@ fun CurrencyCreatorFlowScreen( val initialStack = route.rememberInitialStack() - val topBarController = remember { CurrencyCreatorTopBarController() } - val viewModel = hiltViewModel() val state by viewModel.stateFlow.collectAsStateWithLifecycle() val session = LocalSessionController.current - CompositionLocalProvider(LocalCurrencyCreatorTopBar provides topBarController) { - Column(modifier = Modifier.fillMaxSize()) { - CurrencyCreatorTopBar( - controller = topBarController, - mainContent = when (state.currentStep) { - is CurrencyCreatorStep.Info -> { - { - Text( - text = stringResource(R.string.title_createYourCurrency), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - } - } + SteppedFlowScaffold( + route = route, + initialStack = initialStack, + currentStep = state.currentStep, + resultStateRegistry = resultStateRegistry, + titleContent = currencyCreatorTitleContent(state), + endContent = currencyCreatorEndContent(state), + onExit = { reason, _ -> + val result: CurrencyCreatorResult = when (reason) { + is FlowExitReason.Completed -> reason.result + FlowExitReason.Canceled, + FlowExitReason.BackedOutOfRoot -> CurrencyCreatorResult.Canceled + } - // TokenSelectScreen's own top bar is hidden on this step (showTopBar = false) - // to avoid stacking two bars; surface its title here instead. Matches the - // LaunchFunding title the select screen would show, and like Bill Review this - // step shows no progress indicator (title replaces the progress slot). - is CurrencyCreatorStep.FundingSourceSelection -> { - { - Text( - text = stringResource(R.string.title_selectPaymentCurrency), - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - } - } + outerNavigator.deliverFlowResult( + route = route, + value = NavResultOrCanceled.ReturnValue(result), + ) - is CurrencyCreatorStep.Processing -> { - { - val text = if (state.processingState.success) { - stringResource(R.string.title_success) - } else if (state.processingState.error) { - stringResource(R.string.title_failed) - } else { - stringResource( - R.string.title_creatingCurrency, - state.nameFieldState.text.toString() - ) - } + if (result is CurrencyCreatorResult.Success) { + // The bill is priced through the new token's bonding curve in the + // ViewModel (see buildLaunchBill); presenting it lets someone grab the + // newly created currency. It's null if pricing failed — just close then. + val bill = state.launchBill + if (state.purchaseAmount > Fiat.Zero && bill != null) { + outerNavigator.hide() + session?.showBill(bill) + } else { + outerNavigator.pop() + } + } else { + outerNavigator.pop() + } + }, + entryProvider = currencyCreatorEntryProvider(), + ) +} - Text( - text = text, - style = CodeTheme.typography.textLarge, - color = CodeTheme.colors.textMain, - ) - } - } +/** + * The step bar title for each step, or null on steps that show the progress indicator instead. + * Returns a composable lambda (deferred) rather than rendering, so the scaffold can distinguish + * "has a title" (override the progress slot) from "no title" (show progress). + */ +private fun currencyCreatorTitleContent( + state: CurrencyCreatorViewModel.State, +): (@Composable () -> Unit)? = when (state.currentStep) { + is CurrencyCreatorStep.Info -> { + { + Text( + text = stringResource(R.string.title_createYourCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + } + } - else -> null - }, - endContent = when (state.currentStep) { - is CurrencyCreatorStep.BillCustomization -> { - { - val (accent, onAccent) = rememberDynamicAccent( - fallbackAccent = CodeTheme.colors.secondary, - fallbackOnAccent = CodeTheme.colors.onAction, - ) + // TokenSelectScreen's own top bar is hidden on this step (showTopBar = false) + // to avoid stacking two bars; surface its title here instead. Matches the + // LaunchFunding title the select screen would show, and like Bill Review this + // step shows no progress indicator (title replaces the progress slot). + is CurrencyCreatorStep.FundingSourceSelection -> { + { + Text( + text = stringResource(R.string.title_selectPaymentCurrency), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + } + } - Text( - text = stringResource(R.string.action_next), - style = CodeTheme.typography.textMedium, - color = onAccent, - modifier = Modifier - .background(accent, shape = CircleShape) - .unboundedClickable { - topBarController.onEndAction?.invoke() - } - .padding( - horizontal = CodeTheme.dimens.grid.x2, - vertical = CodeTheme.dimens.grid.x1 - ), - ) - } - } + is CurrencyCreatorStep.Processing -> { + { + val text = if (state.processingState.success) { + stringResource(R.string.title_success) + } else if (state.processingState.error) { + stringResource(R.string.title_failed) + } else { + stringResource( + R.string.title_creatingCurrency, + state.nameFieldState.text.toString() + ) + } - else -> null - }, + Text( + text = text, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, ) + } + } - FlowHost( - initialStack = initialStack, - resultStateRegistry = resultStateRegistry, - onExit = { reason, _ -> - val result: CurrencyCreatorResult = when (reason) { - is FlowExitReason.Completed -> reason.result - FlowExitReason.Canceled, - FlowExitReason.BackedOutOfRoot -> CurrencyCreatorResult.Canceled - } + else -> null +} - outerNavigator.deliverFlowResult( - route = route, - value = NavResultOrCanceled.ReturnValue(result), - ) +/** + * The step bar end action for each step, or null when the step has none. Bill Customization surfaces + * a "Next" capsule that fires the shared step bar's end action (wired in [SyncStepBar]). + */ +private fun currencyCreatorEndContent( + state: CurrencyCreatorViewModel.State, +): (@Composable () -> Unit)? = when (state.currentStep) { + is CurrencyCreatorStep.BillCustomization -> { + { + val stepBar = LocalFlowStepBar.current + val (accent, onAccent) = rememberDynamicAccent( + fallbackAccent = CodeTheme.colors.secondary, + fallbackOnAccent = CodeTheme.colors.onAction, + ) - if (result is CurrencyCreatorResult.Success) { - // The bill is priced through the new token's bonding curve in the - // ViewModel (see buildLaunchBill); presenting it lets someone grab the - // newly created currency. It's null if pricing failed — just close then. - val bill = state.launchBill - if (state.purchaseAmount > Fiat.Zero && bill != null) { - outerNavigator.hide() - session?.showBill(bill) - } else { - outerNavigator.pop() - } - } else { - outerNavigator.pop() + Text( + text = stringResource(R.string.action_next), + style = CodeTheme.typography.textMedium, + color = onAccent, + modifier = Modifier + .background(accent, shape = CircleShape) + .unboundedClickable { + stepBar.onEndAction?.invoke() } - }, - entryProvider = currencyCreatorEntryProvider(), + .padding( + horizontal = CodeTheme.dimens.grid.x2, + vertical = CodeTheme.dimens.grid.x1 + ), ) } } + + else -> null } private fun currencyCreatorEntryProvider(): (NavKey) -> NavEntry = entryProvider { - annotatedEntry { SyncTopBar(it); InfoScreen() } - annotatedEntry { SyncTopBar(it); NameSelectionScreen() } - annotatedEntry { SyncTopBar(it); IconSelectionScreen() } - annotatedEntry { SyncTopBar(it); DescriptionSelectionScreen() } - annotatedEntry { SyncTopBar(it); BillCustomizationScreen() } - annotatedEntry { SyncTopBar(it); BillReviewScreen() } - annotatedEntry { SyncTopBar(it); FundingSourceSelectionScreen() } - annotatedEntry { SyncTopBar(it); ProcessingScreen() } + annotatedEntry { SyncStepBar(it); InfoScreen() } + annotatedEntry { SyncStepBar(it); NameSelectionScreen() } + annotatedEntry { SyncStepBar(it); IconSelectionScreen() } + annotatedEntry { SyncStepBar(it); DescriptionSelectionScreen() } + annotatedEntry { SyncStepBar(it); BillCustomizationScreen() } + annotatedEntry { SyncStepBar(it); BillReviewScreen() } + annotatedEntry { SyncStepBar(it); FundingSourceSelectionScreen() } + annotatedEntry { SyncStepBar(it); ProcessingScreen() } } /** - * Syncs the current step to the ViewModel, which in turn pushes progress and - * visibility to the top bar controller. Also wires up the back button. + * Wires the shared step bar for the current step: dispatches OnStepChanged (the ViewModel keeps + * currentStep for title switching), and sets the shared controller's back / end-action. Progress is + * computed by SteppedFlowScaffold, not here. */ @Composable -private fun SyncTopBar(step: CurrencyCreatorStep) { - val topBar = LocalCurrencyCreatorTopBar.current +private fun SyncStepBar(step: CurrencyCreatorStep) { + val stepBar = LocalFlowStepBar.current val billPlaygroundController = LocalBillPlaygroundController.current val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() - LaunchedEffect(viewModel, topBar) { - viewModel.connectTopBar(topBar) - } LaunchedEffect(step) { viewModel.dispatchEvent(CurrencyCreatorViewModel.Event.OnStepChanged(step)) if (step is CurrencyCreatorStep.Processing) { - topBar.onBack = null + stepBar.onBack = null } else { - topBar.onBack = { flowNavigator.back() } + stepBar.onBack = { flowNavigator.back() } } - topBar.onEndAction = { + stepBar.onEndAction = { val bill = billPlaygroundController.state.value.customizedBill viewModel.dispatchEvent(CurrencyCreatorViewModel.Event.OnBillConfirmed(bill)) flowNavigator.navigateTo(CurrencyCreatorStep.BillReview) @@ -247,7 +250,7 @@ private fun CurrencyCreatorPreview( ) { CompositionLocalProvider( LocalFlowNavigator provides PreviewFlowNavigator(), - LocalCurrencyCreatorTopBar provides remember { CurrencyCreatorTopBarController() }, + LocalFlowStepBar provides remember { FlowStepBarController() }, ) { val state = CurrencyCreatorViewModel.State(feeAmount = feeAmount) content(state) diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt index f6abed44e..f0b7028f6 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/CurrencyCreatorViewModel.kt @@ -11,7 +11,6 @@ import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.tokens.CurrencyCreatorDraft import com.flipcash.app.core.tokens.CurrencyCreatorStep import com.flipcash.app.currencycreator.CurrencyCreatorCoordinator -import com.flipcash.app.currencycreator.internal.components.CurrencyCreatorTopBarController import com.flipcash.app.payments.PurchaseMethodController import com.flipcash.app.tokens.BalancePoller import com.flipcash.app.tokens.TokenCoordinator @@ -134,14 +133,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( get() = descriptionFieldState.text.isNotBlank() && remainingSpaceForDescription >= 0 - val progress: Float - get() { - val step = currentStep ?: return 0f - val index = PROGRESS_STEPS.indexOfFirst { it.isInstance(step) } - if (index < 0) return 0f - return (index + 1).toFloat() / PROGRESS_STEPS.size - } - val totalCost: Fiat get() { val fee = feeAmount.orZero() @@ -658,21 +649,6 @@ internal class CurrencyCreatorViewModel @Inject constructor( ) } - /** - * Connect the ViewModel to the top bar controller. The ViewModel pushes - * [State.progress] to the controller whenever the current step changes. - */ - fun connectTopBar(controller: CurrencyCreatorTopBarController) { - stateFlow - .map { it.currentStep } - .distinctUntilChanged() - .onEach { _ -> - val state = stateFlow.value - controller.progress = state.progress - } - .launchIn(viewModelScope) - } - internal companion object { val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt index b5b42f936..2bcefc69e 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/InfoScreen.kt @@ -15,15 +15,19 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep +import com.flipcash.app.core.ui.flow.FlowStepper +import com.flipcash.app.core.ui.flow.StepperItem import com.flipcash.app.currencycreator.internal.CurrencyCreatorViewModel -import com.flipcash.app.currencycreator.internal.components.Stepper import com.flipcash.core.R +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.minus import com.getcode.navigation.flow.flowSharedViewModel import com.getcode.navigation.flow.rememberFlowNavigator import kotlinx.coroutines.flow.filterIsInstance @@ -106,12 +110,78 @@ internal fun InfoScreenContent( ) } - Stepper( + FlowStepper( modifier = Modifier .weight(0.5f) .padding(top = CodeTheme.dimens.grid.x12), - cost = state.totalCost, - fee = state.feeAmount + items = currencyCreatorStepperItems(cost = state.totalCost, fee = state.feeAmount), + ) + } + } +} + +@Composable +private fun currencyCreatorStepperItems(cost: Fiat, fee: Fiat?): List { + val receiving = cost - (fee ?: Fiat.Zero) + val isReceivingAmount = receiving > Fiat.Zero + + return buildList { + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_name), + title = stringResource(R.string.title_currencyCreatorStepName), + description = stringResource(R.string.subtitle_currencyCreatorStepName), + ) + ) + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_icon), + title = stringResource(R.string.title_currencyCreatorStepIcon), + description = stringResource(R.string.subtitle_currencyCreatorStepIcon), + ) + ) + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_description), + title = stringResource(R.string.title_currencyCreatorStepDescription), + description = stringResource(R.string.subtitle_currencyCreatorStepDescription), + ) + ) + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_cashbill), + title = stringResource(R.string.title_currencyCreatorStepDesign), + description = stringResource(R.string.subtitle_currencyCreatorStepDesign), + ) + ) + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_purchase), + title = stringResource( + R.string.title_currencyCreatorStepPurchase, + cost.formatted( + rule = Fiat.FormattingRule.Truncated, + suffix = stringResource(R.string.subtitle_usdSuffix) + ) + ), + description = stringResource(R.string.subtitle_currencyCreatorStepPurchase), + showConnector = isReceivingAmount, + ) + ) + if (isReceivingAmount) { + add( + StepperItem( + icon = painterResource(R.drawable.ic_currencycreator_gift), + title = stringResource( + R.string.title_currencyCreatorStepPurchaseFreeGift, + receiving.formatted(rule = Fiat.FormattingRule.Truncated) + ), + description = stringResource( + R.string.subtitle_currencyCreatorStepPurchaseFreeGift, + receiving.formatted(rule = Fiat.FormattingRule.Truncated) + ), + showConnector = false, + ) ) } }