diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index 40e8047a96..6a3b76287a 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -232,6 +232,7 @@ dependencies { implementation(project(":apps:flipcash:features:discovery")) implementation(project(":apps:flipcash:features:user-profile")) implementation(project(":apps:flipcash:features:userflags")) + implementation(project(":apps:flipcash:features:tipping")) implementation(project(":libs:crypto:solana")) implementation(project(":libs:datetime")) @@ -259,7 +260,7 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.androidx.navigation.ui) implementation(libs.androidx.work) - implementation("androidx.webkit:webkit:1.16.0") + implementation(libs.androidx.webkit) //hilt dependency injection implementation(libs.hilt.android) @@ -273,7 +274,7 @@ dependencies { testImplementation(libs.hilt.android.test) kspTest(libs.hilt.android.compiler) - androidTestImplementation("io.mockk:mockk:1.14.11") + androidTestImplementation(libs.mockk) //Jetpack compose implementation(platform(libs.compose.bom)) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index ba138c98db..e8aca90166 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -26,6 +26,7 @@ import com.flipcash.app.balance.BalanceScreen import com.flipcash.app.cash.CashScreen import com.flipcash.app.contact.verification.VerificationFlowScreen import com.flipcash.app.currencycreator.CurrencyCreatorFlowScreen +import com.flipcash.app.tipping.TippingFlowScreen import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.currency.RegionSelectionScreen @@ -92,6 +93,9 @@ fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } annotatedEntry { SendFlowScreen(resultStateRegistry = resultStateRegistry) } + annotatedEntry { key -> + TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) + } annotatedEntry { key -> TokenSelectScreen(key.purpose) } annotatedEntry { BalanceScreen() } annotatedEntry { ShareAppScreen() } 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 3ca213b4fe..f04985db3c 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 @@ -170,6 +170,10 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Send(val resumed: Boolean = false): Sheets + @Serializable + data class Tips(val resumed: Boolean = false): Sheets { + } + @Serializable data object Wallet : Sheets @Serializable diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/BillState.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/BillState.kt index 55d11b06d9..20457a97de 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/BillState.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/bill/BillState.kt @@ -11,7 +11,7 @@ import com.getcode.opencode.model.financial.Token import kotlin.time.Duration data class BillState( - val bill: Scannable.Payable?, + val bill: Scannable?, val showToast: Boolean, val toast: BillToast?, val valuation: Valuation?, @@ -19,10 +19,15 @@ data class BillState( val secondaryAction: Action?, ) { val canSwipeToDismiss: Boolean - get() = bill?.canSwipeToDismiss == true + get() = when (val b = bill) { + null -> false + is Scannable.Payable -> b.canSwipeToDismiss + else -> true // non-payment scannables (e.g. TipCard) are dismissable + } val confirmationDelayMillis: Int - get() = (bill?.confirmationDelay ?: Duration.ZERO).inWholeMilliseconds.toInt() + get() = ((bill as? Scannable.Payable)?.confirmationDelay ?: Duration.ZERO) + .inWholeMilliseconds.toInt() companion object { val Default = BillState( diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt index 27a910bf3c..7801fbb7b1 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt @@ -5,9 +5,10 @@ enum class NavBarButton { Wallet, Discover, Send, + Tips, ; companion object { - val defaultOrder = listOf(Discover, Give, Send, Wallet,) + val defaultOrder = listOf(Discover, Give, Send, Tips, Wallet,) } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt new file mode 100644 index 0000000000..e6a9ff3a5e --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipResult.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.core.tipping + +import android.os.Parcelable +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +sealed interface TipResult : Parcelable { + @Parcelize + @Serializable + data object Success : TipResult + + @Parcelize + @Serializable + data object Canceled : TipResult +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt new file mode 100644 index 0000000000..7a389d112c --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tipping/TipStep.kt @@ -0,0 +1,24 @@ +package com.flipcash.app.core.tipping + +import android.os.Parcelable +import com.getcode.navigation.NonDismissableRoute +import com.getcode.navigation.NonDraggableRoute +import com.getcode.navigation.flow.FlowStep +import kotlinx.parcelize.Parcelize +import kotlinx.serialization.Serializable + +@Serializable +sealed interface TipStep : FlowStep, Parcelable, NonDismissableRoute, NonDraggableRoute { + + @Parcelize + @Serializable + data object Intro : TipStep + + @Parcelize + @Serializable + data object TipCard: TipStep + + @Parcelize + @Serializable + data object Tips : TipStep +} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index ea0ff0d85f..ac9b3c37d1 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -1,6 +1,7 @@ package com.flipcash.app.core.ui import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -60,6 +61,7 @@ import com.getcode.ui.utils.widthOrZero data class NavigationBarState( val contactDmUnreadCount: Int = 0, + val tipUnreadCount: Int = 0, val showToast: Boolean = false, val toastText: String? = null, val isPaused: Boolean = false, @@ -93,6 +95,7 @@ fun NavigationBar( verticalAlignment = Alignment.Bottom, horizontalArrangement = Arrangement.SpaceAround, ) { + val imageSize by animateDpAsState(if (config.order.size < 5) CodeTheme.dimens.staticGrid.x10 else CodeTheme.dimens.staticGrid.x7) config.order.forEachIndexed { index, button -> val buttonModifier = if (reorderState != null) { Modifier.weight(1f).longPressDraggable(reorderState, index) @@ -106,6 +109,7 @@ fun NavigationBar( label = stringResource(config.giveButtonLabel.labelRes), painter = painterResource(R.drawable.ic_cash_bill), badgeCount = 0, + imageSize = imageSize, onClick = { onButtonClick(NavBarButton.Give) } ) NavBarButton.Wallet -> BottomBarAction( @@ -113,6 +117,7 @@ fun NavigationBar( label = stringResource(R.string.action_wallet), painter = painterResource(R.drawable.ic_flipcash_balance), onClick = { onButtonClick(NavBarButton.Wallet) }, + imageSize = imageSize, toast = { AnimatedVisibility( visible = state.showToast && state.toastText != null, @@ -138,6 +143,7 @@ fun NavigationBar( label = stringResource(R.string.action_discover), painter = painterResource(R.drawable.ic_coins), badgeCount = 0, + imageSize = imageSize, onClick = { onButtonClick(NavBarButton.Discover) } ) @@ -146,8 +152,18 @@ fun NavigationBar( label = stringResource(R.string.action_send), badgeCount = state.contactDmUnreadCount, painter = painterResource(R.drawable.ic_send_outlined), + imageSize = imageSize, onClick = { onButtonClick(NavBarButton.Send) } ) + + NavBarButton.Tips -> BottomBarAction( + modifier = buttonModifier, + label = stringResource(R.string.action_tips), + badgeCount = state.tipUnreadCount, + painter = painterResource(R.drawable.ic_tipping_hand), + imageSize = imageSize, + onClick = { onButtonClick(NavBarButton.Tips) } + ) } } } diff --git a/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand.xml b/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand.xml new file mode 100644 index 0000000000..b4d0bcf4d6 --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml b/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml new file mode 100644 index 0000000000..66f3de265f --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_tipping_hand_large.xml @@ -0,0 +1,28 @@ + + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 3744cf07da..b787f077c6 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -855,6 +855,12 @@ Amount to buy Exchange fee + Tips + Tips + Receive Tips From Everyone + Add your name and a picture to receive tips + Start Receiving Tips + What is your name? Your Name This Name is Not Allowed @@ -863,4 +869,6 @@ CThis photo will be shown when receiving tips Your Name 500x500 Recommended + My Tip Card + \ No newline at end of file diff --git a/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/BillCustomizationScaffold.kt b/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/BillCustomizationScaffold.kt index 720d2ea37a..808faca55b 100644 --- a/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/BillCustomizationScaffold.kt +++ b/apps/flipcash/features/bill-customization/src/main/kotlin/com/flipcash/app/bill/customization/BillCustomizationScaffold.kt @@ -40,11 +40,9 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bill.customization.components.BillPlayground -import com.flipcash.app.bills.AnimatedBill -import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.bills.AnimatedScannable import com.flipcash.features.bill.playground.R import com.getcode.theme.CodeTheme -import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.core.measured import androidx.compose.foundation.clickable import com.getcode.ui.utils.AnimationUtils @@ -104,7 +102,7 @@ fun BillPlaygroundScaffold(content: @Composable () -> Unit) { Box(modifier = Modifier.fillMaxSize()) { content() - AnimatedBill( + AnimatedScannable( modifier = Modifier.fillMaxSize(), dismissState = billDismissState, dismissed = !isUsingPlayground, diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/BillCustomizationScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/BillCustomizationScreen.kt index 0feaa046d7..a482bba356 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/BillCustomizationScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/BillCustomizationScreen.kt @@ -18,8 +18,7 @@ import com.flipcash.app.bill.customization.Event import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.bill.customization.PlaygroundContext import com.flipcash.app.bill.customization.components.BillPlayground -import com.flipcash.app.bills.RenderedBill -import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.core.ui.transitions.SharedTransition import com.flipcash.app.core.ui.transitions.sharedBoundsTransition import com.flipcash.app.currencycreator.internal.CurrencyCreatorViewModel @@ -88,7 +87,7 @@ internal fun BillCustomizationContent( horizontalAlignment = Alignment.CenterHorizontally ) { augmentedBill?.let { bill -> - RenderedBill( + ScannableRenderer( modifier = Modifier .padding(top = CodeTheme.dimens.grid.x3) .fillMaxWidth() @@ -96,7 +95,7 @@ internal fun BillCustomizationContent( .sharedBoundsTransition( transition = SharedTransition.CurrencyBill ), - bill = bill, + scannable = bill, ) } diff --git a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt index 88e9c1f5aa..b931601f8e 100644 --- a/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt +++ b/apps/flipcash/features/currency-creator/src/main/kotlin/com/flipcash/app/currencycreator/internal/screens/ReviewScreen.kt @@ -16,7 +16,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.bills.RenderedBill +import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.core.tokens.CurrencyCreatorResult import com.flipcash.app.core.tokens.CurrencyCreatorStep import com.flipcash.app.core.ui.TokenIconWithName @@ -32,7 +32,6 @@ import com.getcode.ui.theme.CodeButton import com.getcode.ui.theme.CodeScaffold import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @Composable @@ -120,14 +119,14 @@ internal fun ReviewContent( } state.bill?.let { bill -> - RenderedBill( + ScannableRenderer( modifier = Modifier .weight(1f) .fillMaxWidth() .sharedBoundsTransition( transition = SharedTransition.CurrencyBill ), - bill = bill, + scannable = bill, ) } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 8812d15abf..dc1bac0cb7 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -18,7 +18,7 @@ import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.router.LocalRouter -import com.flipcash.app.scanner.internal.bills.BillContainer +import com.flipcash.app.scanner.internal.bills.ScannableContainer import com.flipcash.app.session.LocalSessionController import com.getcode.libs.code.detection.CodeScanResult import com.getcode.navigation.core.LocalCodeNavigator @@ -74,7 +74,7 @@ internal fun Scanner() { } @SuppressLint("LocalContextGetResourceValueCall") - BillContainer( + ScannableContainer( isPaused = isPaused, isPinching = isPinching, zoomRatio = zoomRatio, @@ -90,7 +90,7 @@ internal fun Scanner() { navigator.openAsSheet(route) } - return@BillContainer + return@ScannableContainer } } else -> Unit diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt index d2b1f49e21..ecd7c32837 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt @@ -11,4 +11,5 @@ sealed class ScannerDecorItem(val screen: AppRoute) { data object Logo: ScannerDecorItem(AppRoute.Sheets.ShareApp) data object Discover: ScannerDecorItem(AppRoute.Token.Discovery) data object Send: ScannerDecorItem(AppRoute.Sheets.Send()) + data object Tips: ScannerDecorItem(AppRoute.Sheets.Tips()) } \ No newline at end of file diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt similarity index 70% rename from apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt rename to apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt index f4fe75ffe0..66c8818025 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/BillContainerView.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/bills/ScannableContainer.kt @@ -34,9 +34,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.bills.AnimatedBill +import com.flipcash.app.bills.AnimatedScannable import com.flipcash.app.core.android.extensions.launchAppSettings +import com.flipcash.app.core.bill.Scannable import com.flipcash.app.scanner.internal.ScannerDecorItem import com.flipcash.app.scanner.internal.ui.components.DecorView import com.flipcash.app.scanner.internal.ui.modals.ReceivedFundsConfirmation @@ -59,10 +59,11 @@ import com.getcode.ui.utils.AnimationUtils import com.getcode.util.permissions.PermissionResult import com.getcode.util.permissions.rememberCameraPermission import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds @OptIn(ExperimentalMaterialApi::class) @Composable -internal fun BillContainer( +internal fun ScannableContainer( modifier: Modifier = Modifier, isPaused: Boolean, isPinching: Boolean = false, @@ -183,7 +184,7 @@ internal fun BillContainer( LaunchedEffect(dismissed) { if (dismissed) { - delay(500) + delay(500.milliseconds) dismissed = false } } @@ -216,7 +217,7 @@ internal fun BillContainer( } } - AnimatedBill( + AnimatedScannable( modifier = Modifier.fillMaxSize(), dismissState = billDismissState, dismissed = dismissed, @@ -240,58 +241,84 @@ internal fun BillContainer( } ) - //Bill management options - AnimatedVisibility( - modifier = Modifier - .align(BottomCenter) - .measured { managementHeight = it.height }, - visible = showManagementOptions, - enter = fadeIn(), - exit = fadeOut(tween(100)), - ) { - var canCancel by remember { - mutableStateOf(false) - } - BillManagementOptions( - modifier = Modifier - .windowInsetsPadding(WindowInsets.navigationBars), - primaryAction = updatedBillState.primaryAction, - secondaryAction = updatedBillState.secondaryAction, - isSending = updatedState.isRemoteSendLoading, - isInteractable = canCancel, - ) - - LaunchedEffect(transition.isRunning, transition.targetState) { - // wait for spring settle to enable cancel to not prematurely cancel - // the enter. doing so causing the exit of the bill to not run, or run its own dismiss animation - if (transition.targetState == EnterExitState.Visible && transition.currentState == transition.targetState) { - delay(500) - canCancel = true - } - } - - BackHandler(canCancel) { - session.dismissBill(PutInWallet) - } + // Below-bill content, folded by scannable type. `displayedScannable` retains the + // last shown scannable so an arm's modal can still animate OUT as `bill` returns to + // null on dismiss (the arm stays mounted; only `visible` flips). + var displayedScannable by remember { mutableStateOf(null) } + LaunchedEffect(updatedBillState.bill) { + updatedBillState.bill?.let { displayedScannable = it } } - //Bill Received Bottom Dialog - AnimatedVisibility( - modifier = Modifier.align(BottomCenter), - visible = updatedBillState.bill?.didReceive ?: false, - enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis), - exit = AnimationUtils.modalExit, - ) { - if (updatedBillState.bill != null) { - Box( - contentAlignment = BottomCenter + when (val shown = displayedScannable) { + is Scannable.Payable -> { + //Bill management options + AnimatedVisibility( + modifier = Modifier + .align(BottomCenter) + .measured { managementHeight = it.height }, + visible = updatedBillState.bill is Scannable.Payable && showManagementOptions, + enter = fadeIn(), + exit = fadeOut(tween(100)), ) { - ReceivedFundsConfirmation( - bill = updatedBillState.bill!!, - onClaim = { session.dismissBill(PutInWallet) } + var canCancel by remember { + mutableStateOf(false) + } + BillManagementOptions( + modifier = Modifier + .windowInsetsPadding(WindowInsets.navigationBars), + primaryAction = updatedBillState.primaryAction, + secondaryAction = updatedBillState.secondaryAction, + isSending = updatedState.isRemoteSendLoading, + isInteractable = canCancel, ) + + LaunchedEffect(transition.isRunning, transition.targetState) { + // wait for spring settle to enable cancel to not prematurely cancel + // the enter. doing so causing the exit of the bill to not run, or run its own dismiss animation + if (transition.targetState == EnterExitState.Visible && transition.currentState == transition.targetState) { + delay(500) + canCancel = true + } + } + + BackHandler(canCancel) { + session.dismissBill(PutInWallet) + } + } + + //Bill Received Bottom Dialog + AnimatedVisibility( + modifier = Modifier.align(BottomCenter), + visible = (updatedBillState.bill as? Scannable.Payable)?.didReceive == true, + enter = AnimationUtils.modalEnter(billState.confirmationDelayMillis), + exit = AnimationUtils.modalExit, + ) { + Box( + contentAlignment = BottomCenter + ) { + ReceivedFundsConfirmation( + bill = shown, + onClaim = { session.dismissBill(PutInWallet) } + ) + } } } + + is Scannable.TipCard -> { + // TODO(owner): the tip card's bottom modal (analogous to ReceivedFundsConfirmation). + // Gate `visible` on the live bill so it animates out on dismiss, and use `shown` + // (the retained Scannable.TipCard) for content so it persists through the exit: + // AnimatedVisibility( + // modifier = Modifier.align(BottomCenter), + // visible = updatedBillState.bill is Scannable.TipCard, + // enter = AnimationUtils.modalEnter(0), + // exit = AnimationUtils.modalExit, + // ) { + // TipCardModal(tipCard = shown, onDone = { session.dismissBill(PutInWallet) }) + // } + } + + null -> Unit } } } diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt index a84ca89720..e85e740026 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt @@ -31,9 +31,19 @@ internal fun ScannerNavigationBar( NavBarConfig.deserialize(navBarConfigString) } - val effectiveConfig = remember(config, state.isPhoneNumberSendEnabled) { - if (state.isPhoneNumberSendEnabled) config - else config.copy(order = config.order.filter { it != NavBarButton.Send }) + val effectiveConfig = remember(config, state.isPhoneNumberSendEnabled, state.isTippingEnabled) { + val buttons = config.order + .filter { option -> + when (option) { + NavBarButton.Send -> state.isPhoneNumberSendEnabled + NavBarButton.Tips -> state.isTippingEnabled + else -> true + } + } + + config.copy( + order = buttons, + ) } NavigationBar( @@ -51,6 +61,7 @@ internal fun ScannerNavigationBar( NavBarButton.Wallet -> ScannerDecorItem.Wallet NavBarButton.Discover -> ScannerDecorItem.Discover NavBarButton.Send -> ScannerDecorItem.Send + NavBarButton.Tips -> ScannerDecorItem.Tips } onAction(item) }, diff --git a/apps/flipcash/features/tipping/build.gradle.kts b/apps/flipcash/features/tipping/build.gradle.kts new file mode 100644 index 0000000000..9b6f5b0de6 --- /dev/null +++ b/apps/flipcash/features/tipping/build.gradle.kts @@ -0,0 +1,15 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.features.tipping" +} + +dependencies { + implementation(project(":services:flipcash")) + implementation(project(":apps:flipcash:shared:bills")) + implementation(project(":apps:flipcash:shared:chat")) + implementation(project(":apps:flipcash:shared:chat-ui")) + implementation(project(":apps:flipcash:shared:tipping")) +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt new file mode 100644 index 0000000000..8d41ee8fa8 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/TippingFlowScreen.kt @@ -0,0 +1,70 @@ +package com.flipcash.app.tipping + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation3.runtime.NavEntry +import androidx.navigation3.runtime.NavKey +import androidx.navigation3.runtime.entryProvider +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 +import com.getcode.navigation.flow.flowSharedViewModel +import com.getcode.navigation.results.NavResultStateRegistry +import com.getcode.navigation.scenes.LocalBottomSheetDismissDispatcher +import com.getcode.ui.utils.rememberKeyboardController + +@Composable +fun TippingFlowScreen( + route: AppRoute.Sheets.Tips, + resultStateRegistry: NavResultStateRegistry, +) { + val sheetDismiss = LocalBottomSheetDismissDispatcher.current + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + val keyboard = rememberKeyboardController() + + // How the flow was entered gates the step list (post-setup handoff → TipCard alone). + LaunchedEffect(route.resumed) { + viewModel.dispatchEvent(TipFlowViewModel.Event.OnResumed(route.resumed)) + } + + FlowHost( + steps = state.steps, + resumeAt = 0, + resultStateRegistry = resultStateRegistry, + // Proceeding off the end (TipCard's Done, or backing out at root) completes the flow. + completedResult = TipResult.Success, + onExit = { reason, _ -> + when (reason) { + is FlowExitReason.Completed, + FlowExitReason.BackedOutOfRoot, + FlowExitReason.Canceled -> keyboard.hideIfVisible { sheetDismiss() } + } + }, + entryProvider = tippingEntryProvider(), + ) +} + +private fun tippingEntryProvider(): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { SyncStep(it); TipInfoScreen() } + annotatedEntry { SyncStep(it); TipCardScreen() } + annotatedEntry { SyncStep(it); TipsScreen() } +} + +@Composable +private fun SyncStep(step: TipStep) { + val viewModel = flowSharedViewModel() + LaunchedEffect(step) { + viewModel.dispatchEvent(TipFlowViewModel.Event.OnStepChanged(step)) + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt new file mode 100644 index 0000000000..bc06fe4a24 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/TipFlowViewModel.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.tipping.internal + +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.extensions.onResult +import com.flipcash.app.core.tipping.TipStep +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.tipping.TippingCoordinator +import com.getcode.view.BaseViewModel +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import javax.inject.Inject + +@HiltViewModel +internal class TipFlowViewModel @Inject constructor( + chatCoordinator: ChatCoordinator, + userManager: UserManager, + tippingCoordinator: TippingCoordinator, +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, +) { + + data class State( + val steps: List = listOf(TipStep.Tips), + // true when re-entering right after the user-profile setup handoff (Tips(resumed = true)). + val resumed: Boolean = false, + val currentStep: TipStep? = null, + val tipChats: List = emptyList(), + val tipCard: Scannable.TipCard? = null, + ) + + sealed interface Event { + data class StepsUpdated(val steps: List) : Event + data class OnStepChanged(val step: TipStep) : Event + data class ChatsUpdated(val tips: List) : Event + + /** How the flow was entered — [resumed] is true for the post-setup handoff re-entry. */ + data class OnResumed(val resumed: Boolean) : Event + data class OnTipCardPopulated(val card: Scannable.TipCard) : Event + } + + init { + combine( + userManager.state.map { it.userProfile }.distinctUntilChanged(), + stateFlow.map { it.resumed }.distinctUntilChanged(), + ) { profile, resumed -> + buildList { + val hasProfile = profile?.displayName != null && profile.profilePicture != null + when { + // Post-setup handoff: land on TipCard alone, with a close affordance. No Tips + // underneath — closing exits, and reopening from home lands on the Tips list. + resumed -> add(TipStep.TipCard) + // Fresh open, incomplete profile: start setup (Intro → user-profile flow). + !hasProfile -> add(TipStep.Intro) + // Fresh open, set up: the Tips list. + else -> add(TipStep.Tips) + } + } + }.onEach { + dispatchEvent(Event.StepsUpdated(it)) + }.launchIn(viewModelScope) + + // Rebuild the current user's tip card whenever their profile becomes available/changes. + userManager.state + .mapNotNull { it.userProfile } + .distinctUntilChanged() + .map { tippingCoordinator.resolveTipCard() } + .onResult(onSuccess = { card -> dispatchEvent(Event.OnTipCardPopulated(card)) }) + .launchIn(viewModelScope) + + chatCoordinator.feed(ChatType.TIP_DM) + .onEach { summaries -> dispatchEvent(Event.ChatsUpdated(summaries.map { it.toPreview() })) } + .launchIn(viewModelScope) + } + + private fun ChatSummary.toPreview(): ConversationReference { + return ConversationReference( + chatId = metadata.chatId, + lastMessagePreview = null, // TODO: + unreadCount = unreadCount, + ) + } + + companion object { + private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.OnStepChanged -> { state -> state.copy(currentStep = event.step) } + is Event.StepsUpdated -> { state -> state.copy(steps = event.steps) } + is Event.ChatsUpdated -> { state -> state.copy(tipChats = event.tips) } + is Event.OnResumed -> { state -> state.copy(resumed = event.resumed) } + is Event.OnTipCardPopulated -> { state -> state.copy(tipCard = event.card) } + } + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt new file mode 100644 index 0000000000..1ffffa5e2d --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipCardScreen.kt @@ -0,0 +1,105 @@ +package com.flipcash.app.tipping.internal.screens + +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.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.bills.ScannableRenderer +import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.tipping.TipResult +import com.flipcash.app.core.tipping.TipStep +import com.flipcash.app.tipping.internal.TipFlowViewModel +import com.flipcash.features.tipping.R +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.theme.CodeScaffold + +@Composable +internal fun TipCardScreen() { + val flowNavigator = rememberFlowNavigator() + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_myTipCard), + titleAlignment = Alignment.CenterHorizontally, + backButton = true, + onBackIconClicked = { flowNavigator.back() }, + ) + }, + 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( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + CircularIconButton( + imageSize = CodeTheme.dimens.staticGrid.x6, + buttonSize = CodeTheme.dimens.grid.x12, + onClick = {} + ) { size -> + Icon( + painter = painterResource(R.drawable.ic_remote_send), + contentDescription = "", + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + 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, + ) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x10), + text = "Share Your Tipcard to Get Tipped", + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Spacer(Modifier.weight(1f)) + state.tipCard?.let { + ScannableRenderer( + scannable = it, + ) + } + Spacer(Modifier.weight(1f)) + } + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt new file mode 100644 index 0000000000..d69398290d --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt @@ -0,0 +1,111 @@ +package com.flipcash.app.tipping.internal.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.send.SendResult +import com.flipcash.app.core.send.SendStep +import com.flipcash.app.core.tipping.TipResult +import com.flipcash.app.core.tipping.TipStep +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.core.R +import com.getcode.navigation.flow.LocalFlowNavigator +import com.getcode.navigation.flow.PreviewFlowNavigator +import com.getcode.navigation.flow.rememberFlowNavigator +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 + +@Composable +internal fun TipInfoScreen() { + val flowNavigator = rememberFlowNavigator() + val userManager = LocalUserManager.current + CodeScaffold( + topBar = { + AppBarWithTitle( + endContent = { AppBarDefaults.Close { flowNavigator.exitCanceled() }}) + }, + bottomBar = { + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .navigationBarsPadding() + .padding(vertical = CodeTheme.dimens.grid.x3), + text = stringResource(R.string.action_startReceivingTips), + buttonState = ButtonState.Filled, + onClick = { + flowNavigator.navigate( + AppRoute.UpdateUserProfile( + origin = AppRoute.Sheets.Tips(), + includeName = userManager?.profile?.displayName.isNullOrEmpty(), + includePhoto = userManager?.profile?.profilePicture == null, + target = AppRoute.Sheets.Tips(resumed = true), + ) + ) + }, + ) + } + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Image( + painter = painterResource(R.drawable.ic_tipping_hand_large), + contentDescription = null, + ) + + Text( + text = stringResource(R.string.title_tipIntro), + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + ) + Text( + modifier = Modifier + .padding(top = CodeTheme.dimens.grid.x2) + .padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.subtitle_tipIntro), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_TipIntro() { + CompositionLocalProvider( + LocalFlowNavigator provides PreviewFlowNavigator(), + ) { + TipInfoScreen() + } +} diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt new file mode 100644 index 0000000000..e1d87545f7 --- /dev/null +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipsScreen.kt @@ -0,0 +1,75 @@ +package com.flipcash.app.tipping.internal.screens + +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.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.tipping.TipStep +import com.flipcash.app.tipping.internal.TipFlowViewModel +import com.flipcash.features.tipping.R +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 + +@Composable +internal fun TipsScreen() { + val viewModel = flowSharedViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + val navigator = LocalCodeNavigator.current + CodeScaffold( + topBar = { + AppBarWithTitle( + title = stringResource(R.string.title_tips), + titleAlignment = Alignment.CenterHorizontally, + endContent = { + AppBarDefaults.Close { navigator.hide() } + } + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + 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 = "Show My Tipcard", + buttonState = ButtonState.Filled, + onClick = { + navigator.navigate(TipStep.TipCard) + }, + ) + } + } + + items(state.tipChats) { chat -> + + } + } + } +} diff --git a/apps/flipcash/shared/bills/build.gradle.kts b/apps/flipcash/shared/bills/build.gradle.kts index 59db6ea754..82828d6657 100644 --- a/apps/flipcash/shared/bills/build.gradle.kts +++ b/apps/flipcash/shared/bills/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { implementation(libs.firebase.messaging) implementation(project(":libs:messaging")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(libs.androidx.datastore) } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedBill.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt similarity index 85% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedBill.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt index aee3aaa183..58ddcbd364 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedBill.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/AnimatedScannable.kt @@ -20,7 +20,7 @@ import com.getcode.ui.theme.CustomSwipeToDismiss @OptIn(ExperimentalMaterialApi::class) @Composable -fun AnimatedBill( +fun AnimatedScannable( modifier: Modifier = Modifier, contentPadding: PaddingValues = PaddingValues( horizontal = CodeTheme.dimens.inset, @@ -28,9 +28,9 @@ fun AnimatedBill( ), dismissState: DismissState, dismissed: Boolean, - transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform, - contentKey: (Scannable.Payable?) -> Any? = { it }, - bill: Scannable.Payable?, + transitionSpec: AnimatedContentTransitionScope.() -> ContentTransform, + contentKey: (Scannable?) -> Any? = { it }, + bill: Scannable?, ) { AnimatedContent( modifier = modifier, @@ -47,16 +47,16 @@ fun AnimatedBill( state = dismissState, dismissContent = { if (b != null && !dismissed) { - RenderedBill( + ScannableRenderer( modifier = Modifier .fillMaxWidth() .weight(1f) .padding(contentPadding), - bill = b + scannable = b ) } }, - directions = if (b?.disableGestures ?: false) { + directions = if ((b as? Scannable.Payable)?.disableGestures == true) { emptySet() } else { setOf(DismissDirection.EndToStart, DismissDirection.StartToEnd) diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/RenderedBill.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/RenderedBill.kt deleted file mode 100644 index b7378da579..0000000000 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/RenderedBill.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.flipcash.app.bills - -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.tooling.preview.Preview -import com.flipcash.app.core.bill.Scannable -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.Rate -import com.getcode.opencode.model.financial.Token -import com.getcode.theme.CodeTheme -import com.getcode.theme.DesignSystem - -@Composable -fun RenderedBill( - modifier: Modifier = Modifier, - bill: Scannable.Payable, -) { - when (bill) { - is Scannable.CashBill -> CashBill( - modifier = modifier, - payloadData = bill.data, - amount = bill.amount, - token = bill.token - ) - is Scannable.GoldBar -> GoldBar( - modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), - payloadData = bill.data, - amount = bill.amount.underlyingTokenAmount, - ) - } -} - -private val PREVIEW_CODE_DATA = listOf( - 0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0, - 0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE, - 0x6F, 0x03, 0xB4, 0x87, 0x2C, 0xE5, 0x50, 0xA9, - 0x1E, 0x73, 0xC6, 0x3F, 0x98, 0x41, 0xDA, 0x65, - 0x0B, 0xF2, 0x7D, 0xAE, 0x53, 0xC0, 0x19, -).map { it.toByte() } - -@Preview -@Composable -fun Preview_CashBill() { - DesignSystem { - // $3 USD - val usdcBase = Fiat(3.00, CurrencyCode.USD) - val cadRate = Rate(1.4, CurrencyCode.CAD) - CashBill( - amount = LocalFiat( - usdf = usdcBase, - nativeAmount = usdcBase.convertingTo(cadRate), - ), - mint = "5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ", - payloadData = PREVIEW_CODE_DATA, - ) - } -} \ No newline at end of file diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt new file mode 100644 index 0000000000..0c002a1eae --- /dev/null +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableRenderer.kt @@ -0,0 +1,80 @@ +package com.flipcash.app.bills + +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.bills.components.bills.CashBill +import com.flipcash.app.bills.components.bills.GoldBar +import com.flipcash.app.bills.components.cards.TipCard +import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.UserProfile +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.theme.CodeTheme + +@Composable +fun ScannableRenderer( + modifier: Modifier = Modifier, + scannable: Scannable, +) { + when (scannable) { + is Scannable.CashBill -> CashBill( + modifier = modifier, + payloadData = scannable.data, + amount = scannable.amount, + token = scannable.token + ) + is Scannable.GoldBar -> GoldBar( + modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), + payloadData = scannable.data, + amount = scannable.amount.underlyingTokenAmount, + ) + is Scannable.TipCard -> TipCard( + modifier = modifier, + payloadData = scannable.data, + user = scannable.user + ) + } +} + +private val PREVIEW_CODE_DATA = listOf( + 0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0, + 0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE, + 0x6F, 0x03, 0xB4, 0x87, 0x2C, 0xE5, 0x50, 0xA9, + 0x1E, 0x73, 0xC6, 0x3F, 0x98, 0x41, 0xDA, 0x65, + 0x0B, 0xF2, 0x7D, 0xAE, 0x53, 0xC0, 0x19, +).map { it.toByte() } + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +fun Preview_CashBill() { + // $3 USD + val usdcBase = Fiat(3.00, CurrencyCode.USD) + val cadRate = Rate(1.4, CurrencyCode.CAD) + CashBill( + amount = LocalFiat( + usdf = usdcBase, + nativeAmount = usdcBase.convertingTo(cadRate), + ), + mint = "5AMAA9JV9H97YYVxx8F6FsCMmTwXSuTTQneiup4RYAUQ", + payloadData = PREVIEW_CODE_DATA, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +fun Preview_TipCard() { + TipCard( + payloadData = PREVIEW_CODE_DATA, + user = UserProfile.Empty.copy( + displayName = "Flipcash User", + ) + ) +} \ No newline at end of file diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableCode.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/ScannableCode.kt similarity index 98% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableCode.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/ScannableCode.kt index 2e115b20e3..b17554e132 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/ScannableCode.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/ScannableCode.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.bills +package com.flipcash.app.bills.components import android.graphics.Bitmap import android.graphics.BitmapFactory diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillAmount.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/BillAmount.kt similarity index 92% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillAmount.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/BillAmount.kt index d9c0e92b1e..11645e3080 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/BillAmount.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/BillAmount.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.bills +package com.flipcash.app.bills.components.bills import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -9,9 +9,6 @@ import androidx.compose.ui.draw.rotate import androidx.compose.ui.layout.layout import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.min -import androidx.compose.ui.unit.sp import com.flipcash.shared.bills.R import com.getcode.theme.CodeTheme import com.getcode.ui.utils.nonScaledSp diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/CashBill.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt similarity index 97% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/CashBill.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt index 5f970098ad..45e562a608 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/CashBill.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/CashBill.kt @@ -1,6 +1,5 @@ -package com.flipcash.app.bills +package com.flipcash.app.bills.components.bills -import android.R.attr.fillType import android.annotation.SuppressLint import android.graphics.Bitmap import android.graphics.BitmapFactory @@ -35,8 +34,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clipToBounds -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.draw.rotate import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect @@ -56,7 +53,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.compositeOver import androidx.compose.ui.graphics.drawscope.DrawScope -import androidx.compose.ui.graphics.drawscope.clipPath import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.lerp import androidx.compose.ui.layout.ContentScale @@ -77,28 +73,22 @@ import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.isSpecified -import androidx.compose.ui.zIndex +import com.flipcash.app.bills.components.ScannableCode import com.flipcash.app.core.money.formatted import com.flipcash.shared.bills.R import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.LocalFiat import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.ui.BillBackground -import com.getcode.opencode.model.ui.BillTexture import com.getcode.opencode.model.ui.TokenBillCustomizations -import com.getcode.solana.keys.Mint import com.getcode.opencode.model.ui.BlendMode as PlaygroundBlendMode import com.getcode.solana.keys.base58 import com.getcode.theme.CodeTheme -import com.getcode.ui.core.blendMode -import com.getcode.ui.core.drawWithGradient import com.getcode.ui.core.patternBlend import com.getcode.ui.core.punchCircle import com.getcode.ui.core.punchRectangle import com.getcode.ui.utils.Geometry -import com.getcode.ui.utils.deriveTargetColor import com.getcode.ui.utils.hexToColor -import com.getcode.ui.utils.hls import com.getcode.ui.utils.nonScaledSp import kotlin.math.ceil import kotlin.math.roundToInt @@ -665,7 +655,7 @@ private class BillPunchShape( fun punchColorsFrom(billColors: List): List { return billColors.map { color -> - lerp(color, Color.Black, 0.30f) + lerp(color, Black, 0.30f) } } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/GoldBar.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/GoldBar.kt similarity index 97% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/GoldBar.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/GoldBar.kt index 245e0e450e..b5b7a220cd 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/GoldBar.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/bills/GoldBar.kt @@ -1,6 +1,12 @@ -package com.flipcash.app.bills +package com.flipcash.app.bills.components.bills import android.content.Context +import android.graphics.BitmapShader +import android.graphics.Matrix +import android.graphics.PorterDuff +import android.graphics.PorterDuffXfermode +import android.graphics.RuntimeShader +import android.graphics.Shader import android.hardware.Sensor import android.hardware.SensorEvent import android.hardware.SensorEventListener @@ -78,18 +84,20 @@ import androidx.compose.ui.unit.dp import androidx.core.graphics.createBitmap import androidx.core.graphics.set import androidx.compose.ui.res.stringResource +import com.flipcash.app.bills.components.ScannableCode import com.flipcash.shared.bills.R import com.getcode.opencode.model.financial.Fiat import com.getcode.solana.keys.Mint import com.getcode.solana.keys.base58 import com.getcode.theme.DesignSystem import com.getcode.ui.utils.nonScaledSp +import kotlin.math.abs import kotlin.random.Random import android.graphics.Paint as NativePaint @OptIn(ExperimentalLayoutApi::class) @Composable -fun GoldBar( +internal fun GoldBar( modifier: Modifier = Modifier, payloadData: List = emptyList(), amount: Fiat, @@ -326,20 +334,20 @@ private fun Modifier.brushedMetal( shape.createOutline(size, layoutDirection, Density(density)) ) } - val streakShader = android.graphics.BitmapShader( + val streakShader = BitmapShader( streakBitmap, - android.graphics.Shader.TileMode.REPEAT, - android.graphics.Shader.TileMode.REPEAT, + Shader.TileMode.REPEAT, + Shader.TileMode.REPEAT, ).apply { - val matrix = android.graphics.Matrix() + val matrix = Matrix() matrix.setRotate(highlightRotation) setLocalMatrix(matrix) } val streakPaint = NativePaint().apply { shader = streakShader alpha = grainAlpha - xfermode = android.graphics.PorterDuffXfermode( - android.graphics.PorterDuff.Mode.OVERLAY + xfermode = PorterDuffXfermode( + PorterDuff.Mode.OVERLAY ) } onDrawBehind { @@ -614,7 +622,7 @@ private fun Modifier.recessWithCodeCutout( val grooveCount = 50 val strokePx = 1f // exactly 1 physical pixel — crisp, no AA blur val groovePaint = NativePaint().apply { - style = android.graphics.Paint.Style.STROKE + style = NativePaint.Style.STROKE strokeWidth = strokePx isAntiAlias = false // pixel-sharp, no soft halos } @@ -723,8 +731,8 @@ private fun Modifier.recessWithCodeCutout( canvas.nativeCanvas.drawRect( 0f, 0f, size.width, size.height, NativePaint().apply { - xfermode = android.graphics.PorterDuffXfermode( - android.graphics.PorterDuff.Mode.SRC_ATOP + xfermode = PorterDuffXfermode( + PorterDuff.Mode.SRC_ATOP ) color = argb }, @@ -755,8 +763,8 @@ private fun Modifier.recessWithCodeCutout( val sc = canvas.nativeCanvas.saveLayer( null, NativePaint().apply { - xfermode = android.graphics.PorterDuffXfermode( - android.graphics.PorterDuff.Mode.DST_OUT + xfermode = PorterDuffXfermode( + PorterDuff.Mode.DST_OUT ) }, ) @@ -874,7 +882,7 @@ private fun Modifier.agslBrushedMetal( lightSource: Offset = DefaultLightSource, rotation: Float = 0f, ): Modifier = this.drawWithCache { - val shader = android.graphics.RuntimeShader(AGSL_BRUSHED_METAL) + val shader = RuntimeShader(AGSL_BRUSHED_METAL) shader.setFloatUniform("iResolution", size.width, size.height) val sl = safeLight(lightSource) shader.setFloatUniform("iTilt", sl.x, sl.y) @@ -1107,9 +1115,9 @@ private fun rememberTiltState(): State { // Dead zone — only update state if change is perceptible val prev = state.value - val dx = kotlin.math.abs(smoothX - prev.x) - val dy = kotlin.math.abs(smoothY - prev.y) - val dr = kotlin.math.abs(smoothRot - prev.rotation) + val dx = abs(smoothX - prev.x) + val dy = abs(smoothY - prev.y) + val dr = abs(smoothRot - prev.rotation) if (dx > deadZone || dy > deadZone || dr > 0.5f) { state.value = TiltState(smoothX, smoothY, smoothRot) } diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt new file mode 100644 index 0000000000..d21b1baa37 --- /dev/null +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/cards/TipCard.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.bills.components.cards + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +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.unit.dp +import com.flipcash.app.bills.components.ScannableCode +import com.flipcash.services.models.UserProfile +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme +import com.getcode.theme.xxl + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TipCard( + payloadData: List, + user: UserProfile, + modifier: Modifier = Modifier, +) { + BoxWithConstraints( + modifier = modifier + .windowInsetsPadding(WindowInsets.statusBarsIgnoringVisibility) + .padding(horizontal = CodeTheme.dimens.inset), + contentAlignment = Alignment.Center + ) { + val mW = this.maxWidth + val codeSize = remember { mW * 0.65f } + + Column( + modifier = Modifier.background(CodeTheme.colors.tipCardColor, shape = CodeTheme.shapes.xxl) + .padding(vertical = CodeTheme.dimens.grid.x8, horizontal = CodeTheme.dimens.grid.x7) + .heightIn(0.dp, 800.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x4) + ) { + if (payloadData.isNotEmpty()) { + ScannableCode( + modifier = Modifier.size(codeSize), + data = payloadData, + icon = null, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Tip", + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + + ContactAvatar( + modifier = Modifier + .padding(start = CodeTheme.dimens.grid.x2, end = CodeTheme.dimens.grid.x1) + .size(CodeTheme.dimens.staticGrid.x5) + .clip(CircleShape), + userProfile = user + ) + + Text( + text = user.displayName.orEmpty(), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/PaymentReceiptBill.kt b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/receipts/PaymentReceipt.kt similarity index 97% rename from apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/PaymentReceiptBill.kt rename to apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/receipts/PaymentReceipt.kt index 1038e6881e..7f8999114e 100644 --- a/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/PaymentReceiptBill.kt +++ b/apps/flipcash/shared/bills/src/main/kotlin/com/flipcash/app/bills/components/receipts/PaymentReceipt.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.bills +package com.flipcash.app.bills.components.receipts import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -26,6 +26,7 @@ import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.flipcash.app.bills.components.ScannableCode import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.LocalFiat diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 383ad11bd9..0199e18e58 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -257,6 +257,15 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } + @FeatureFlagMarker + data object Tipping: FeatureFlag { + override val key: String = "tipping_enabled" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = false + } + companion object { val entries: List> get() = FeatureFlagEntries.entries @@ -294,6 +303,7 @@ val FeatureFlag<*>.title: String FeatureFlag.GiveUsdf -> "Give/Send USDF" FeatureFlag.AddMoneyUX -> "Add Money UX" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" + FeatureFlag.Tipping -> "Tipping" } val FeatureFlag<*>.message: String @@ -322,6 +332,7 @@ val FeatureFlag<*>.message: String FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly and give it as cash" FeatureFlag.AddMoneyUX -> "When enabled, the user experience for getting money into the app will be focused around 'Adding Money'" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" + FeatureFlag.Tipping -> "When enabled, you'll gain the ability to tip other users and set up your own tip card to receive tips" } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index e0c059fdd6..04ab50fa0f 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -21,6 +21,13 @@ data object PutInWallet : BillDeterminationResult, ActedUpon interface BillOperations { val billState: StateFlow fun showBill(bill: Scannable.Payable) + + /** + * Presents a non-payment [Scannable.TipCard] in the bill container. Unlike [showBill], + * this runs no grab/await transaction and sets no valuation — the card is simply shown + * until dismissed. + */ + fun showTipCard(tipCard: Scannable.TipCard) fun dismissBill(action: BillDeterminationResult) } @@ -60,8 +67,10 @@ data class SessionState( val restrictionType: RestrictionType? = null, val isRemoteSendLoading: Boolean = false, val contactDmUnreadCount: Int = 0, + val tipsUnreadCount: Int = 0, val tokens: List = emptyList(), val isPhoneNumberSendEnabled: Boolean = false, + val isTippingEnabled: Boolean = false, val addMoneyUx: Boolean = false, ) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 474758471a..d8fbdaa6c5 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -9,6 +9,7 @@ import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.blob.BlobStorageCoordinator import com.flipcash.services.models.chat.ChatType import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.internal.updater.ProfileUpdater import com.flipcash.app.featureflags.FeatureFlag @@ -202,6 +203,15 @@ class RealSessionController @Inject constructor( .onEach { count -> stateHolder.update { it.copy(contactDmUnreadCount = count) } } .launchIn(scope) + userManager.state + .map { it.authState } + .filter { it.isAtLeastRegistered } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeUnreadConversations(ChatType.TIP_DM) } + .distinctUntilChanged() + .onEach { count -> stateHolder.update { it.copy(tipsUnreadCount = count) } } + .launchIn(scope) + // Preload the blob upload policy once registered so profile-photo selection can filter and // validate against it without a network round-trip. Cached in the BlobStorageCoordinator. userManager.state @@ -252,6 +262,10 @@ class RealSessionController @Inject constructor( .onEach { enabled -> stateHolder.update { it.copy(isPhoneNumberSendEnabled = enabled) } } .launchIn(scope) + featureFlagController.observe(FeatureFlag.Tipping) + .onEach { enabled -> stateHolder.update { it.copy(isTippingEnabled = enabled) } } + .launchIn(scope) + // Retry updateUserFlags when network is restored networkObserver.state .map { it.connected } @@ -292,7 +306,8 @@ class RealSessionController @Inject constructor( toastController.clear() val bill = billController.state.value.bill - if (!shareSheetController.isCheckingForShare || (bill != null && !bill.didReceive)) { + if (!shareSheetController.isCheckingForShare || + (bill != null && (bill as? Scannable.Payable)?.didReceive != true)) { BottomBarManager.clear() billController.cancelAwaitForGrab() dismissBill(PutInWallet) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt index 5f4d08d534..c6bfd7686e 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -112,6 +112,14 @@ class BillPresentationDelegate @Inject constructor( } } + override fun showTipCard(tipCard: Scannable.TipCard) { + // Single bill slot — don't clobber a bill that's already presented. + if (billController.state.value.bill != null) return + // No grab/await, no valuation: just place the card in the container. + billController.update { it.copy(bill = tipCard, valuation = null) } + stateHolder.update { it.copy(billResult = PutInWallet) } + } + override fun dismissBill(action: BillDeterminationResult) { scope.launch { stateHolder.update { it.copy(billResult = action) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt index c0a4becff9..d8090faf1b 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt @@ -140,7 +140,7 @@ class GiftCardSharingDelegate @Inject constructor( message = "Cash link not sent. Restarting awaiting grab", type = TraceType.User, ) - val currentBill = billController.state.value.bill ?: bill + val currentBill = billController.state.value.bill as? Scannable.Payable ?: bill _events.trySend(Event.RestartBillGrab(currentBill, owner)) } } diff --git a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt index e6fc52cc61..30da635d44 100644 --- a/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt +++ b/apps/flipcash/shared/theme/src/main/kotlin/com/flipcash/app/theme/internal/FlipcashDesignSystem.kt @@ -118,6 +118,7 @@ private val colors = with(Flipcash2ColorSpec) { toggleUncheckedTrackColor = Color(0xFF666666), cashBill = cashBill, cashBillDecorColor = Color.White.copy(0.60f), + tipCard = Color.Black.copy(alpha = 0.36f), betaIndicator = BetaIndicator, bannerThemed = bannerThemed, bannerError = Error, diff --git a/gradle.properties b/gradle.properties index 0d37ec7fd0..42df4d3ba9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -45,3 +45,6 @@ android.dependency.useConstraints=false # enum unboxing, constant propagation). Produces smaller, faster output # and can also reduce R8's own processing time. android.enableR8.fullMode=true + +# Enabled parallel sync for Gradle 9.4+ +org.gradle.tooling.parallel=true diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt index f215521c1d..6b108116e8 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/services/ResolverService.kt @@ -21,24 +21,22 @@ internal class ResolverService @Inject constructor( return runCatching { api.resolve(owner, identifier) }.foldWithSuppression( - onSuccess = { it.toResult() }, + onSuccess = { response -> + when (response.result) { + RpcResolverService.ResolveResponse.Result.OK -> + Result.success(response.resolution.address.toPublicKey()) + RpcResolverService.ResolveResponse.Result.NOT_FOUND -> + Result.failure(ResolveContactError.NotFound()) + RpcResolverService.ResolveResponse.Result.DENIED -> + Result.failure(ResolveContactError.Denied()) + RpcResolverService.ResolveResponse.Result.UNRECOGNIZED -> + Result.failure(ResolveContactError.Unrecognized()) + else -> Result.failure(ResolveContactError.Other()) + } + }, onFailure = { cause -> Result.failure(cause.toValidationOrElse { ResolveContactError.Other(cause = it) }) } ) } - - private fun RpcResolverService.ResolveResponse.toResult(): Result { - return when (result) { - RpcResolverService.ResolveResponse.Result.OK -> - Result.success(resolution.address.toPublicKey()) - RpcResolverService.ResolveResponse.Result.NOT_FOUND -> - Result.failure(ResolveContactError.NotFound()) - RpcResolverService.ResolveResponse.Result.DENIED -> - Result.failure(ResolveContactError.Denied()) - RpcResolverService.ResolveResponse.Result.UNRECOGNIZED -> - Result.failure(ResolveContactError.Unrecognized()) - else -> Result.failure(ResolveContactError.Other()) - } - } } diff --git a/settings.gradle.kts b/settings.gradle.kts index 4a993c2380..bc5f01db30 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -116,6 +116,7 @@ include( ":apps:flipcash:features:discovery", ":apps:flipcash:features:user-profile", ":apps:flipcash:features:userflags", + ":apps:flipcash:features:tipping", // protobuf model and service implementations for the Open Code Protocol ":definitions:opencode:models", diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt index 1dfd9cbc99..0a4168cb04 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Shape.kt @@ -28,7 +28,7 @@ val Shapes.extraLarge: CornerBasedShape @Composable get() = RoundedCornerShape(20.dp) val Shapes.xxl: CornerBasedShape - @Composable get() = RoundedCornerShape(25.dp) + @Composable get() = RoundedCornerShape(30.dp) @Composable fun Shapes.receipt(step: Dp = CodeTheme.dimens.grid.x2) = TriangleCutShape(step) diff --git a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt index f542514804..3decfbd3eb 100644 --- a/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt +++ b/ui/theme/src/main/kotlin/com/getcode/theme/Theme.kt @@ -52,6 +52,7 @@ internal val CodeDefaultColorScheme = ColorScheme( toggleUncheckedTrackColor = BrandToggleUncheckedTrackColor, cashBill = CashBill, cashBillDecorColor = CashBillDecor, + tipCard = Brand, betaIndicator = BetaIndicator, bannerThemed = Brand, bannerError = Error, @@ -153,6 +154,7 @@ class ColorScheme( toggleUncheckedTrackColor: Color, cashBill: Color, cashBillDecorColor: Color, + tipCard: Color, betaIndicator: Color, bannerThemed: Color, bannerError: Color, @@ -229,6 +231,8 @@ class ColorScheme( private set var cashBillDecorColor by mutableStateOf(cashBillDecorColor) private set + var tipCardColor by mutableStateOf(tipCard) + private set var betaIndicator by mutableStateOf(betaIndicator) private set @@ -285,6 +289,7 @@ class ColorScheme( toggleUncheckedTrackColor = other.toggleUncheckedTrackColor cashBillColor = other.cashBillColor cashBillDecorColor = other.cashBillDecorColor + tipCardColor = other.tipCardColor betaIndicator = other.betaIndicator bannerThemed = other.bannerThemed bannerError = other.bannerError @@ -330,6 +335,7 @@ class ColorScheme( toggleUncheckedTrackColor = toggleUncheckedTrackColor, cashBill = cashBillColor, cashBillDecorColor = cashBillDecorColor, + tipCard = tipCardColor, betaIndicator = betaIndicator, bannerThemed = bannerThemed, bannerError = bannerError,