diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt index c7b28bb77e..f6966ce2e6 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt @@ -20,6 +20,7 @@ import androidx.compose.animation.ExitTransition import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.animation.slideInHorizontally import androidx.compose.animation.slideOutHorizontally import androidx.compose.animation.togetherWith @@ -91,6 +92,17 @@ private val pushedTokenInfoEntries: List = private fun isGiveKey(key: Any?): Boolean = key?.toString()?.startsWith("Give(") == true +/** + * True when a scene key is [AppRoute.Menu.ProfileCard]. Same stringified-key match as + * [isTokenInfoKey]; a `data object` prints its simple name. + */ +private fun isProfileCardKey(key: Any?): Boolean = + key?.toString() == "ProfileCard" + +/** The profile card's own fade; the screen's spring-in does the rest, so nothing slides. */ +private const val ProfileCardFadeMillis = 250 +private val EaseOutEasing = CubicBezierEasing(0f, 0f, 0.58f, 1f) + @Composable internal fun AppContent( codeNavigator: CodeNavigator, @@ -201,6 +213,11 @@ internal fun AppContent( // CardExpandTransition + TokenCardStack. isTokenInfoKey(targetState.key) -> CardExpandTransition.openEnter togetherWith CardExpandTransition.openExit + // The card presents like a scanned bill: the backdrop fades up over the + // You tab, which stays put beneath, and the screen pops the card itself. + isProfileCardKey(targetState.key) -> + fadeIn(tween(ProfileCardFadeMillis, easing = EaseOutEasing)) togetherWith + ExitTransition.None landsOnTab -> fadeIn(tween(300)) togetherWith fadeOut(tween(300)) else -> @@ -221,6 +238,10 @@ internal fun AppContent( // Swapping in a single frame keeps the scrim continuously up. isGiveKey(initialState.key) -> EnterTransition.None togetherWith ExitTransition.None + // The whole screen fades out at full size, no slide. + isProfileCardKey(initialState.key) -> + EnterTransition.None togetherWith + fadeOut(tween(ProfileCardFadeMillis, easing = EaseOutEasing)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) @@ -234,6 +255,9 @@ internal fun AppContent( isTokenInfoKey(initialState.key) -> CardExpandTransition.predictiveCloseEnter togetherWith CardExpandTransition.predictiveCloseExit + isProfileCardKey(initialState.key) -> + EnterTransition.None togetherWith + fadeOut(tween(ProfileCardFadeMillis, easing = EaseOutEasing)) else -> slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) 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 5d94fa9c27..cb78b1a95e 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 @@ -47,6 +47,8 @@ import com.flipcash.app.lab.LabsScreen import com.flipcash.app.login.AccountSelectionScreen import com.flipcash.app.login.OnboardingFlowScreen import com.flipcash.app.menu.MenuScreen +import com.flipcash.app.menu.ProfileCardScreen +import com.flipcash.app.menu.ProfileShareScreen import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.SettingsScreen @@ -169,6 +171,8 @@ fun appEntryProvider( annotatedEntry { key -> LabsScreen(onboarding = key.onboarding) } annotatedEntry { UserProfileScreen() } annotatedEntry { SettingsScreen() } + annotatedEntry { ProfileShareScreen() } + annotatedEntry { ProfileCardScreen() } annotatedEntry { BlocklistScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { DeviceLogsScreen() } diff --git a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt index 30b7a1f0c7..cb9c858261 100644 --- a/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt +++ b/apps/flipcash/core-ui/src/main/kotlin/com/flipcash/app/core/ui/onboarding/NewUserTutorial.kt @@ -11,7 +11,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.AddCircleOutline -import androidx.compose.material.icons.outlined.Edit import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -26,12 +25,9 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.vector.rememberVectorPainter import androidx.compose.ui.res.painterResource 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.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach -import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.core.R import com.getcode.theme.CodeTheme import com.getcode.theme.extraSmall @@ -39,9 +35,8 @@ import com.getcode.theme.extraSmall /** * A row in a "what's left to do" checklist. * - * Split by the screen that owns it: [Wallet] and [Profile] are drawn by different tabs and share - * nothing but the row layout, so each call site's `when` stays exhaustive over its own family and - * cannot be handed an item it has no branch for. + * [Wallet] is the family the wallet tab draws, so its call site's `when` stays exhaustive over + * the milestones it owns. */ sealed interface TutorialItem { val title: String @@ -55,9 +50,6 @@ sealed interface TutorialItem { /** The wallet tab's new-user milestones. */ sealed interface Wallet : TutorialItem - /** The "You" tab's profile-completion steps (node 9544:18140). */ - sealed interface Profile : TutorialItem - class AddMoney(override val isCompleted: Boolean) : Wallet { override val title: String @Composable get() = stringResource(R.string.title_addMoney) @@ -75,41 +67,6 @@ sealed interface TutorialItem { override val icon: Painter @Composable get() = painterResource(R.drawable.ic_nav_tipcard) } - - /** - * Accounts created before onboarding asked for a name have none, and chat shows them as - * "Flipcash User" until they set one. - */ - class DisplayName(override val isCompleted: Boolean) : Profile { - override val title: String - @Composable get() = stringResource(R.string.title_addDisplayName) - override val description: String - @Composable get() = stringResource(R.string.subtitle_addDisplayName) - override val icon: Painter - @Composable get() = rememberVectorPainter(Icons.Outlined.Edit) - } - - class ProfilePicture(override val isCompleted: Boolean) : Profile { - override val title: String - @Composable get() = stringResource(R.string.title_addProfilePicture) - override val description: String - @Composable get() = stringResource(R.string.subtitle_addProfilePicture) - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_people_circle) - } - - /** - * The fee another user has to pay to open a DM, stored on the profile as `minDmChatInitFee`. - * Completes once one is set; the row stays outstanding while the server default applies. - */ - class MinimumTip(override val isCompleted: Boolean = false) : Profile { - override val title: String - @Composable get() = stringResource(R.string.title_setMinimumTip) - override val description: String - @Composable get() = stringResource(R.string.subtitle_setMinimumTip) - override val icon: Painter - @Composable get() = painterResource(R.drawable.ic_coins) - } } @Composable @@ -210,33 +167,3 @@ private fun OnboardingItemRow( ) } } - -@Preview(name = "Finish Your Profile — nothing done") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun PreviewFinishProfileEmpty() { - NewUserTutorial( - title = stringResource(R.string.title_finishYourProfile), - items = listOf( - TutorialItem.DisplayName(isCompleted = false), - TutorialItem.ProfilePicture(isCompleted = false), - TutorialItem.MinimumTip(), - ), - onItemClicked = {}, - ) -} - -@Preview(name = "Finish Your Profile — photo set") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun PreviewFinishProfilePhotoSet() { - NewUserTutorial( - title = stringResource(R.string.title_finishYourProfile), - items = listOf( - TutorialItem.DisplayName(isCompleted = true), - TutorialItem.ProfilePicture(isCompleted = true), - TutorialItem.MinimumTip(), - ), - onItemClicked = {}, - ) -} 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 d1fbdb5b24..d70d89c45b 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 @@ -328,6 +328,20 @@ sealed interface AppRoute : NavKey, Parcelable { data object AccountSelection : Menu @Serializable data object UserProfile : Menu + + /** + * What to do with your own profile: share it, show its card, copy its link. A + * [com.getcode.navigation.WrapContentSheet], for the reason [Messaging.MuteChat] is one. + */ + @Serializable + data object ShareProfile : + Menu, + com.getcode.navigation.Sheet, + com.getcode.navigation.WrapContentSheet + + /** Your profile card, full screen, with its Download action. */ + @Serializable + data object ProfileCard : Menu @Serializable data class Lab(val onboarding: Boolean = false) : Menu } diff --git a/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml index a711ab1e3a..6f016f0d43 100644 --- a/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml +++ b/apps/flipcash/core/src/main/res/drawable/ic_chevron_down_small.xml @@ -1,6 +1,5 @@ + A 24dp stroked chevron, which sits lighter beside 16sp text than a filled 16dp one. --> Scan a Tip Card Give your first tip - Finish Your Profile - Add your display name - Choose the name people see in chat - Add a profile picture - Select a photo from your gallery - Set your minimum tip amount - Decide what size tip matters to you Amount to Buy Amount to Sell @@ -1206,8 +1199,6 @@ Get a custom @username Get your balance to %1$s or more to unlock - Tap to select your username now - %1$s to go Username Taken Please try a different username @@ -1333,8 +1324,14 @@ Send Your First Tip - Full Screen - Close + Edit Profile + Claim your username › + Share User Profile + + %1$s · @%2$s + Share Profile + Show Profile Card + Copy Link Download Tip Card Link Group Invite Link diff --git a/apps/flipcash/features/menu/build.gradle.kts b/apps/flipcash/features/menu/build.gradle.kts index 588a0898bd..d3f8e3c8a6 100644 --- a/apps/flipcash/features/menu/build.gradle.kts +++ b/apps/flipcash/features/menu/build.gradle.kts @@ -9,13 +9,16 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.robolectric) implementation(libs.bundles.haze) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:bills")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:funding")) + implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:shareable")) implementation(project(":apps:flipcash:shared:tipping")) // Balance for the username gate only — :shared:tokens:core is the narrow half, so the diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt new file mode 100644 index 0000000000..c6ef7d6057 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileCardScreen.kt @@ -0,0 +1,164 @@ +package com.flipcash.app.menu + +import dev.chrisbanes.haze.rememberHazeState +import dev.chrisbanes.haze.hazeSource +import com.flipcash.app.menu.internal.ProfileBarButton +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +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.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.bills.ScannableRenderer +import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha +import com.flipcash.app.bills.components.cards.LocalTipCardColor +import com.flipcash.app.bills.components.cards.TipCardFlattened +import com.flipcash.app.menu.internal.ProfileCardViewModel +import com.flipcash.core.R as CoreR +import com.flipcash.features.menu.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.theme.CodeTheme +import com.getcode.ui.utils.KeepScreenOn +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton + +/** + * The viewer's own profile card, full screen, behind `AppRoute.Menu.ProfileCard`. It presents like + * a scanned bill: the card pops in on a spring while the Close button fades in. The backdrop's + * fade in and the whole screen's fade out belong to the navigation transition, which fades this + * route in place of the default slide. Download, where the You tab keeps its settings gear, hands + * the card to the system share sheet as an image. + */ +@Composable +fun ProfileCardScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + val haptics = LocalHapticFeedback.current + val card = viewModel.card + + var appeared by remember { mutableStateOf(false) } + LaunchedEffect(Unit) { + haptics.performHapticFeedback(HapticFeedbackType.SegmentTick) + appeared = true + } + + val fade by animateFloatAsState( + targetValue = if (appeared) 1f else 0f, + animationSpec = tween(durationMillis = RevealFadeMillis, easing = EaseOut), + label = "profile card fade", + ) + val cardScale by animateFloatAsState( + targetValue = if (appeared) 1f else CardInitialScale, + animationSpec = spring(dampingRatio = CardSpringDamping, stiffness = CardSpringStiffness), + label = "profile card scale", + ) + + // As on iOS: the card is a code someone is about to scan, so a dim screen is raised to 0.6 for + // as long as it is up (the boost is capped there, so anything under 0.4 lands on exactly 0.6). + KeepScreenOn( + isEnabled = true, + useBrightness = true, + minBrightness = ScanMinimumBrightness, + brightnessBoost = ScanBoostedBrightness, + maxBrightness = ScanBoostedBrightness, + ) + + // Everything under the Download button is its frosting source, as the cover is for the You tab's gear. + val hazeState = rememberHazeState() + Box( + modifier = Modifier + .fillMaxSize() + .background(CodeTheme.colors.background), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .hazeSource(hazeState), + ) { + if (card != null) { + // Static backdrop, so draw the card opaque at its flattened tone, as the You tab did. + CompositionLocalProvider( + LocalTipCardColor provides TipCardFlattened, + LocalTipCardBaseAlpha provides 1f, + ) { + ScannableRenderer( + modifier = Modifier + .align(Alignment.Center) + .graphicsLayer { + alpha = fade + scaleX = cardScale + scaleY = cardScale + }, + scannable = card, + tipCardWidth = CodeTheme.dimens.screenWidth * CardWidthFraction, + ) + } + } + } + + // Download stands where the You tab keeps its gear, so the gear reads as turning into it. + // Nothing to export until the card resolves (or if it never does). + if (card != null) { + ProfileBarButton( + modifier = Modifier.align(Alignment.TopEnd), + icon = R.drawable.ic_file_download, + contentDescription = stringResource(R.string.action_download), + onClick = viewModel::download, + hazeState = hazeState, + testTag = "you-download-button", + ) + } + + CodeButton( + modifier = Modifier + .align(Alignment.BottomCenter) + .navigationBarsPadding() + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(bottom = CodeTheme.dimens.grid.x4) + .graphicsLayer { alpha = fade }, + buttonState = ButtonState.Subtle, + onClick = { navigator.pop() }, + text = stringResource(CoreR.string.description_chatMediaClose), + ) + } +} + +private const val ScanMinimumBrightness = 0.4f +private const val ScanBoostedBrightness = 0.6f + +/** How much of the display the card spans, the proportion the earlier full-screen card used. */ +private const val CardWidthFraction = 0.75f + +private const val RevealFadeMillis = 250 + +/** The card's size as it starts to pop in. */ +private const val CardInitialScale = 0.55f + +// SwiftUI `.spring(duration: 0.4, bounce: 0.6)`: damping ratio is 1 - bounce, and stiffness is +// (2 * pi / duration)^2. The damping ratio matches the scanned bill's pop, +// `AnimationUtils.animationBillEnterGrabbed`. +private const val CardSpringDamping = 0.4f +private const val CardSpringStiffness = 247f + +private val EaseOut = CubicBezierEasing(0f, 0f, 0.58f, 1f) diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt new file mode 100644 index 0000000000..e6a7f4ad1d --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/ProfileShareScreen.kt @@ -0,0 +1,60 @@ +package com.flipcash.app.menu + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import com.flipcash.app.core.AppRoute +import com.flipcash.app.menu.internal.ProfileShareViewModel +import com.flipcash.features.menu.R +import com.flipcash.shared.common.ui.profile.ProfileShareRow +import com.flipcash.shared.common.ui.profile.ProfileShareSheet +import com.getcode.navigation.core.LocalCodeNavigator + +private const val RowShare = "share" +private const val RowCard = "card" +private const val RowCopy = "copy" + +/** + * The share sheet for the viewer's own profile, behind `AppRoute.Menu.ShareProfile`. + * + * A row does not act on the tap. It asks the navigator to animate the sheet away and runs the + * action once that has finished, so the system share sheet or the card screen is not opened over a + * sheet that is still on its way out. + */ +@Composable +fun ProfileShareScreen() { + val viewModel = hiltViewModel() + val navigator = LocalCodeNavigator.current + val profile = remember(viewModel) { viewModel.profile } + + val name = profile?.displayName.orEmpty() + val handle = profile?.username + val subtitle = if (handle.isNullOrEmpty()) { + name + } else { + stringResource(R.string.subtitle_shareUserProfile, name, handle) + } + val rows = listOf( + ProfileShareRow(RowShare, R.drawable.ic_share_os, stringResource(R.string.action_shareProfileLink)), + ProfileShareRow(RowCard, R.drawable.ic_qr_code, stringResource(R.string.action_showProfileCard)), + ProfileShareRow(RowCopy, R.drawable.ic_copy, stringResource(R.string.action_copyLink)), + ) + + ProfileShareSheet( + title = stringResource(R.string.title_shareUserProfile), + subtitle = subtitle, + rows = rows, + onRow = { row -> + // A second tap while the sheet animates out would replace the pending action and run both. + if (navigator.pendingSheetDismiss != null) return@ProfileShareSheet + navigator.pendingSheetDismiss = { + when (row.id) { + RowShare -> viewModel.share() + RowCard -> navigator.push(AppRoute.Menu.ProfileCard) + RowCopy -> viewModel.copyLink() + } + } + }, + ) +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt new file mode 100644 index 0000000000..0811b2e495 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/JoinedLabel.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.menu.internal + +import android.text.format.DateFormat +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.time.Instant + +/** + * The join date as the profile's stats card shows it: full month and year ("October 2026"), in + * [locale]'s language and word order. Null when the account has no join date, which the card draws + * as a dash. + * + * The pattern comes from [DateFormat.getBestDateTimePattern] for the `MMMMy` skeleton, so word + * order and connectives follow [locale] ("octubre de 2026" in Spanish). + */ +internal fun joinedLabel( + joinedAt: Instant?, + locale: Locale = Locale.getDefault(), + zone: ZoneId = ZoneId.systemDefault(), +): String? { + joinedAt ?: return null + return DateTimeFormatter.ofPattern(DateFormat.getBestDateTimePattern(locale, "MMMMy"), locale) + .withZone(zone) + .format(java.time.Instant.ofEpochMilli(joinedAt.toEpochMilliseconds())) +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt index efb49b87ff..2ad7c00fa0 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenContent.kt @@ -1,625 +1,270 @@ package com.flipcash.app.menu.internal -import androidx.activity.compose.BackHandler -import androidx.compose.animation.Crossfade -import androidx.compose.animation.core.EaseInOut -import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.draggable -import androidx.compose.foundation.gestures.rememberDraggableState -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsIgnoringVisibility +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.wrapContentSize import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf -import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.rotate import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.compositeOver -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.layout.onGloballyPositioned -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.platform.LocalViewConfiguration -import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.lerp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.bills.ScannableRenderer import com.flipcash.app.bills.components.cards.LocalTipCardBaseAlpha import com.flipcash.app.bills.components.cards.LocalTipCardColor +import com.flipcash.app.bills.components.cards.TipCardFlattened +import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.navigation.HideTabBar import com.flipcash.app.core.navigation.LocalTabBarPadding -import com.flipcash.app.core.AppRoute import com.flipcash.app.menu.internal.MenuScreenViewModel.Event -import com.flipcash.app.menu.internal.MenuScreenViewModel.TipCardState -import com.flipcash.app.core.ui.onboarding.NewUserTutorial -import com.flipcash.app.core.ui.onboarding.TutorialItem -import com.flipcash.app.core.util.abbreviatedLink -import com.flipcash.app.menu.internal.components.UsernameProgress -import com.flipcash.app.menu.internal.components.UsernameProgressCard +import com.flipcash.app.menu.internal.MenuScreenViewModel.ProfileState import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.services.models.UserProfile import com.flipcash.core.R as CoreR import com.flipcash.features.menu.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.shared.common.ui.ContactAvatar +import com.flipcash.shared.common.ui.profile.ProfileActionButton +import com.flipcash.shared.common.ui.profile.ProfileHeader +import com.flipcash.shared.common.ui.profile.ProfileStatsCard import com.getcode.theme.CodeTheme import com.getcode.theme.White -import com.getcode.theme.White05 import com.getcode.theme.White08 -import com.getcode.theme.White50 -import com.getcode.theme.extraSmall -import com.getcode.ui.core.noRippleClickable import com.getcode.ui.core.verticalScrollStateGradient -import com.getcode.ui.utils.sheetResignmentBehavior import com.getcode.ui.theme.CodeScaffold +import com.getcode.ui.utils.sheetResignmentBehavior import dev.chrisbanes.haze.HazeInput +import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.blur.HazeBlurDefaults import dev.chrisbanes.haze.blur.HazeBlurStyle import dev.chrisbanes.haze.blur.HazeColorEffect import dev.chrisbanes.haze.blur.hazeBlur import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState -import kotlinx.coroutines.delay +/** + * The "You" tab: the viewer's own profile, in the same layout other people's profiles use. The + * settings gear floats over the cover's top trailing corner and scrolls away with the page. + */ @Composable internal fun MenuScreenContent(viewModel: MenuScreenViewModel) { val state by viewModel.stateFlow.collectAsStateWithLifecycle() - val listState = rememberLazyListState() - // Full screen is a state of *this* screen, not a destination: the card grows into the middle of - // the display and everything else — rows, footer, tab bar — animates out from under it - // (node 9277:121410). Pushing a route would cross-fade a second copy of the card in instead. - val expansion = rememberTipCardExpansion() - val cardExpanded = expansion.isExpanded - // Only a claimed card expands — the unclaimed stand-in is decoration behind the prompt. - val canExpand = state.tipCard != null + // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as bottom + // content padding — the list then scrolls clear of the bar instead of running under it. Per-entry + // via LocalTabBarPadding, which is only non-zero for tab homes. v1 has no such bar. + val bottomInset = LocalTabBarPadding.current.calculateBottomPadding() - LaunchedEffect(canExpand) { - // Losing the card (sign-out) must not strand the page expanded. - if (!canExpand) expansion.collapse() - } - HideTabBar(hidden = cardExpanded) - BackHandler(enabled = cardExpanded) { expansion.collapse() } - - // No app bar — the card is the first thing on the page (node 9276:4634). The only chrome is the - // settings gear, floated over the page's top trailing corner. + // No app bar: the cover is the first thing on the page, and reaches the top of the display. It + // is not padded for the status bar, so the gear takes that clearance for itself. CodeScaffold { padding -> - BoxWithConstraints( + LazyColumn( modifier = Modifier .fillMaxSize() - .padding(padding), + .padding(padding) + .verticalScrollStateGradient(scrollState = listState, isLongGradient = true) + .sheetResignmentBehavior(listState), + state = listState, + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = bottomInset), ) { - // No app bar in v2, so the page owns its own status-bar clearance; the design puts the - // card 74dp below it (node 9278:7301). - // The settings gear is the first thing in the list, so it scrolls away with the page. - // The row it sits in takes the status-bar clearance plus the button's own height out of - // the card's resting offset, which leaves the card exactly where it was. - val restingTop = (CardTopSpacing - SettingsButtonSize).coerceAtLeast(0.dp) - // The design's width, narrowed only if the display can't hold it inside the page's - // margins — same rule iOS applies. - val expandedCardWidth = minOf( - FullScreenCardWidth, - maxWidth - CodeTheme.dimens.inset * 2, - ) - // How far into the expansion we are. One progress drives all of it — the card's - // size and position, the page fading out beneath it, the Close row — so a swipe that - // stops part-way is as legal a state as either end, and none of it can drift out of - // step with the rest. - // - // Handed down as a lambda and never read here: every consumer below reads it inside a - // graphicsLayer block, which the frame re-runs on its own when the value changes. - // Reading it in composition instead recomposed this entire page — settings list - // included — on every frame of the animation. - val progress = remember(expansion) { { expansion.progress } } - // The card is scaled, not re-laid-out. Animating its width made every frame re-measure - // the card and everything inside it: a new corner radius to clip to, a new font size - // for the name (a full text re-layout), and a new size for the interop View that draws - // the code, which re-derives the code's geometry from scratch on each draw. That is - // work for the UI thread, and there is more of it than a frame has time for. iOS hit - // the same wall and settled on one scale over a card drawn once: laid out, each of the - // card's metrics is a separate animatable value — and the name's font size is not - // animatable at all — so the parts arrive at their new sizes at different moments and - // overlap mid-flight. Scaled, the card travels as one figure. - // - // The card is drawn at [FullScreenCardWidth], the widest it ever gets, so the scale - // only ever samples the drawing down. - // - // Both widths are read here rather than inside the lambda: they are theme-backed now, - // and the lambda the frame calls is not a composable scope. - val restingCardWidth = YouCardWidth - val drawnCardWidth = FullScreenCardWidth - val cardScale = remember(expandedCardWidth, restingCardWidth, drawnCardWidth) { - { lerp(restingCardWidth, expandedCardWidth, progress()) / drawnCardWidth } - } - // v2's tab bar is a hoisted overlay drawn ABOVE this content, so reserve its height as - // bottom content padding — the list then scrolls clear of the bar instead of running - // under it (the version footer was landing behind it). Per-entry via LocalTabBarPadding, - // which is only non-zero for tab homes. v1 has no such bar. - // - // It does not animate away with the expansion. It is content padding, so every frame of - // it relaid the whole list — and by the time the released space could be seen, the list - // has already faded out and slid away under the card. - val bottomInset = LocalTabBarPadding.current.calculateBottomPadding() - // Everything but the card fades out on the expansion and slides down out of the way, - // rather than being removed. Keeping the rows in the layout means nothing reflows on - // the way back (iOS does the same with opacity + offset). - val slideDistance = ContentSlideDistance - val slideAway = remember(progress, slideDistance) { - Modifier.graphicsLayer { - val fraction = progress() - // A settled flick overshoots its end a little, so keep the fade in a legal - // alpha range rather than assuming the progress is one. - alpha = (1f - fraction).coerceIn(0f, 1f) - translationY = slideDistance.toPx() * fraction + when (val profileState = state.profileState) { + // A named account resolves in a frame or two, and a prompt that flashed at it would + // be a lie. The gear is still there, so Settings is never out of reach. + ProfileState.Unknown -> item(key = "settings_gear") { + SettingsGear(onClick = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }) } - } - // The card doesn't hand off to a second copy of itself: the one in the list keeps its - // slot and is drawn travelling out of it, the way iOS offsets the card from its own - // measured frame. Measuring the slot (which never carries the offset) rather than the - // card keeps the measurement out of its own feedback loop. - // - // The slot keeps its resting size for the whole expansion: it is what the card scales - // out of, not something that grows with it. A slot that grew would move its own centre - // mid-flight, and that centre is what the shift measures from, so the card would travel - // against itself — a frame behind its own size the whole way. - var cardSlotCenterY by remember { mutableFloatStateOf(0f) } - val displayCenterY = LocalWindowInfo.current.containerSize.height / 2f - val cardShift = remember(displayCenterY, expansion) { - { - if (cardSlotCenterY <= 0f) 0f - // The overdrag rides on the card's position alone: pushed down past full - // screen the card has nowhere left to go, so it gives a little where it - // stands while its size and every fade hold where the expansion left them. - else (displayCenterY - cardSlotCenterY) * (progress() + expansion.overdrag) + is ProfileState.Unclaimed -> { + // As on iOS: the header keeps its cover, avatar and Edit Profile, with no Share + // (there is nothing to share yet), and the claim prompt takes the stats' place. + item(key = "profile_header") { + OwnProfileHeader( + profile = profileState.profile, + onEdit = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + onShare = null, + onClaimUsername = state.usernameProgress?.let { + { viewModel.dispatchEvent(Event.ClaimUsername) } + }, + onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + ) + } + item(key = "claim_prompt") { + Spacer(Modifier.height(UnclaimedTopSpacing)) + UnclaimedTipCardPrompt( + placeholder = profileState.placeholder, + cardWidth = YouCardWidth, + onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) }, + ) + } } - } - // Swiping the expanded card back up puts it away. The card's own travel is the - // gesture's travel: expanding walks the card DOWN out of its slot into the middle of - // the display — which is what the "Full Screen" chevron points at, and why "Close" - // points back up — so the way out is up, and a finger that covers the distance the card - // has left to go closes it exactly. The card stays under the finger the whole way and - // springs to whichever end the release picks. - // - // It goes on the page rather than on the card, as iOS's does: expanded, the card IS the - // page, and a pull that has to land on the card exactly is a pull that misses — the - // card is 302dp of a display wider than that, with live margin either side. Nothing - // here scrolls while the card is up (see userScrollEnabled below), so there is no - // scroll for the drag to take events from. - val density = LocalDensity.current - val minTravel = with(density) { MinDragTravel.toPx() } - val flingVelocity = with(density) { MinFlingVelocity.toPx() } - val touchSlop = LocalViewConfiguration.current.touchSlop - // The same distance cardShift moves the card, so the card keeps pace with the finger - // exactly. It holds still for the length of the gesture without having to be pinned: - // the slot it measures from no longer grows with the card. - val dragTravel = (displayCenterY - cardSlotCenterY).coerceAtLeast(minTravel) - val cardDrag = Modifier.draggable( - state = rememberDraggableState { delta -> expansion.dragBy(delta / dragTravel) }, - orientation = Orientation.Vertical, - enabled = cardExpanded, - onDragStarted = { expansion.startDrag(touchSlop / dragTravel) }, - onDragStopped = { velocity -> - expansion.settle(velocity / dragTravel, flingVelocity / dragTravel) - }, - ) - - LazyColumn( - modifier = Modifier - .fillMaxSize() - .verticalScrollStateGradient(scrollState = listState, isLongGradient = true) - .sheetResignmentBehavior(listState) - .then(cardDrag), - state = listState, - contentPadding = PaddingValues( - top = restingTop, - bottom = bottomInset, - ), - userScrollEnabled = !cardExpanded, - ) { - item(key = "settings_gear", contentType = "settings_gear") { - Box( - modifier = Modifier - .fillMaxWidth() - .statusBarsPadding() - .then(slideAway), - ) { - IconButton( - onClick = { + is ProfileState.Named -> { + item(key = "profile_header") { + OwnProfileHeader( + profile = profileState.profile, + onEdit = { + // Slice 4 gives this its own screen; Settings is where profile + // editing lives until then. viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, - enabled = !cardExpanded, - modifier = Modifier.align(Alignment.TopEnd), - ) { - Icon( - painter = painterResource(R.drawable.ic_settings_outline), - contentDescription = stringResource(CoreR.string.title_settings), - tint = CodeTheme.colors.textMain, - ) - } + onShare = { viewModel.dispatchEvent(Event.ShareProfile) }, + onClaimUsername = state.usernameProgress?.let { + { viewModel.dispatchEvent(Event.ClaimUsername) } + }, + onSettings = { viewModel.dispatchEvent(Event.OpenScreen(AppRoute.Menu.Settings)) }, + ) } - } - item { - YouHeader( - tipCardState = state.tipCardState, - enabled = !cardExpanded, - expansion = progress, - slideAway = slideAway, - cardScale = cardScale, - cardShift = cardShift, - onCardSlotPositioned = { cardSlotCenterY = it }, - onToggleFullScreen = { expansion.toggle() }, - onCopyLink = { viewModel.dispatchEvent(Event.CopyTipLink) }, - onShare = { viewModel.dispatchEvent(Event.ShareTipCard) }, - onDownload = { viewModel.dispatchEvent(Event.DownloadTipCard) }, - onClaim = { viewModel.dispatchEvent(Event.ClaimTipCard) }, - usernameProgress = state.usernameProgress, - usernameMinimumBalance = state.usernameMinimumBalance, - onClaimUsername = { viewModel.dispatchEvent(Event.ClaimUsername) }, - profileTutorial = state.profileTutorial, - onSetProfilePicture = { - viewModel.dispatchEvent(Event.SetProfilePicture) - }, - onSetMinimumTip = { - viewModel.dispatchEvent(Event.SetMinimumTip) - }, - ) + item(key = "stats") { + ProfileStatsCard( + modifier = Modifier + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.staticGrid.x4), + minimumToChat = state.minimumToChat, + joined = profileState.joined, + ) + } + item(key = "bottom_spacer") { Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) } } } - - // Close sits at the foot of the display rather than under the card (node 9277:121410). - // It fades on the same progress as everything else rather than on a transition of its - // own, so a swipe held half-way leaves it half-faded instead of fully drawn. - // Gated on a derived boolean rather than on the progress itself: the row has to leave - // the layout when the card is down (nothing invisible left at the foot of the page for - // a tap or TalkBack to find), but reading the progress here would recompose the page - // every frame. Derived, it only recomposes when the answer flips. - val closeVisible by remember { derivedStateOf { expansion.progress > 0f } } - if (closeVisible) { - FullScreenToggle( - label = stringResource(R.string.action_closeFullScreen), - chevronRotation = 180f, - modifier = Modifier - .align(Alignment.BottomCenter) - // Drawn over the list, so it needs the pull too — a hand reaching for - // "Close" and pulling instead is the likeliest place to start one. - .then(cardDrag) - .graphicsLayer { alpha = progress().coerceIn(0f, 1f) } - .navigationBarsPadding() - .padding(bottom = CloseBottomSpacing) - .noRippleClickable(enabled = cardExpanded) { expansion.collapse() }, - ) - } } } } -/** The settings gear's touch target, which the card's resting offset is measured past. */ -private val SettingsButtonSize: Dp = 48.dp - -/** Distance from the status bar to the top of the tip card (node 9278:7301: 74). */ -private val CardTopSpacing: Dp - @Composable get() = CodeTheme.dimens.grid.x15 - -/** - * The card at rest (node 9278:7301: 241.636) and expanded (node 9277:121410: 302.21), both measured - * on a 402-wide frame and kept here as the fraction of the display they were drawn at rather than - * the dp they happened to measure on it. iOS pins 302 outright; as a fraction the card holds its - * proportion of a narrower or wider display instead of crowding one and stranding the other. - */ -private const val YouCardWidthFraction = 0.60f -private const val FullScreenCardWidthFraction = 0.75f - -private val YouCardWidth: Dp - @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction - -private val FullScreenCardWidth: Dp - @Composable get() = CodeTheme.dimens.screenWidth * FullScreenCardWidthFraction - -/** Gap between the Close row and the system nav bar (node 9277:121410). */ -private val CloseBottomSpacing: Dp - @Composable get() = CodeTheme.dimens.grid.x2 - -/** How far the page's content slides down as it fades out under the expanding card. */ -private val ContentSlideDistance: Dp - @Composable get() = CodeTheme.dimens.grid.x12 - -/** - * How much of the expansion the "Full Screen" caption has to be gone within — a fraction of the - * progress, not of the duration, so a slow drag fades it on exactly the terms a spring does. - * - * It is short because the card is chasing it. The card's lower edge travels down from the slot at - * the sum of its own growth and its shift to the middle of the display — about 224dp of travel per - * unit of progress on a 1080x2424 screen, and more on a taller one — against the 24dp of clearance - * between the two. So the card is over this spot by a tenth of the way out, and the caption cannot - * still be legible when it gets there. - */ -private const val CaptionFadeTravel = 0.08f - -/** - * The "You" tab header (node 9276:4634): the viewer's own tip card with a "Full Screen" affordance, - * the copyable tip link, and the Share / Download tiles. - * - * An account with no display name has no card yet, and gets [UnclaimedTipCardPrompt] in its place - * rather than an empty page. Nothing is drawn while the state is still [TipCardState.Unknown] — a - * named account resolves in a frame or two, and a prompt that flashed at it would be a lie. - */ +/** The viewer's profile header with the settings gear over the cover's top trailing corner. */ @Composable -private fun YouHeader( - tipCardState: TipCardState, - enabled: Boolean, - expansion: () -> Float, - slideAway: Modifier, - cardScale: () -> Float, - cardShift: () -> Float, - onCardSlotPositioned: (Float) -> Unit, - onToggleFullScreen: () -> Unit, - onCopyLink: () -> Unit, - onShare: () -> Unit, - onDownload: () -> Unit, - onClaim: () -> Unit, - usernameProgress: UsernameProgress?, - usernameMinimumBalance: String, - onClaimUsername: () -> Unit, - profileTutorial: List?, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> Unit, +private fun OwnProfileHeader( + profile: UserProfile?, + onEdit: () -> Unit, + onShare: (() -> Unit)?, + onClaimUsername: (() -> Unit)?, + onSettings: () -> Unit, ) { - when (tipCardState) { - TipCardState.Unknown -> Unit - is TipCardState.Unclaimed -> UnclaimedTipCardPrompt( - placeholder = tipCardState.placeholder, - // An unclaimed stand-in never expands, so it is only ever the resting card. - cardWidth = YouCardWidth, - enabled = enabled, - onClaim = onClaim, - profileTutorial = profileTutorial, - onSetProfilePicture = onSetProfilePicture, - onSetMinimumTip = onSetMinimumTip, + // The header is what scrolls under the gear, so it is the gear's frosting source. + val hazeState = rememberHazeState() + Box { + ProfileHeader( + modifier = Modifier.hazeSource(hazeState), + cover = profile?.coverPicture, + // The viewer's own blobs, so no profile id is needed to authorize re-minting them. + access = BlobAccessContext.Owned, + avatar = { modifier -> + ContactAvatar( + image = profile?.profilePicture, + displayName = profile?.displayName.orEmpty(), + access = BlobAccessContext.Owned, + modifier = modifier, + ) + }, + title = profile?.displayName?.ifEmpty { null }, + subtitle = profile?.username?.takeIf { it.isNotEmpty() }?.let { "@$it" }, + body = profile?.bio?.ifEmpty { null }, + // As on iOS: a one-line offer under the handle while there is no handle, or only an + // auto-assigned one. The tap goes through the balance gate like the old progress card. + underSubtitle = { + if (onClaimUsername != null) { + Text( + modifier = Modifier + .padding(top = CodeTheme.dimens.staticGrid.x1) + .clickable(onClick = onClaimUsername) + .padding(vertical = CodeTheme.dimens.staticGrid.x1) + .testTag("you-claim-username"), + text = stringResource(R.string.action_claimYourUsernameLink), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + } + }, + actions = { + ProfileActionButton( + text = stringResource(R.string.action_editProfile), + onClick = onEdit, + ) + // Only a named profile has something to share. + if (onShare != null) { + ProfileActionButton( + icon = ImageVector.vectorResource(R.drawable.ic_share_os), + contentDescription = stringResource(R.string.action_share), + onClick = onShare, + ) + } + }, ) - is TipCardState.Claimed -> ClaimedTipCard( - card = tipCardState.card, - link = tipCardState.link, - enabled = enabled, - expansion = expansion, - slideAway = slideAway, - cardScale = cardScale, - cardShift = cardShift, - onCardSlotPositioned = onCardSlotPositioned, - onToggleFullScreen = onToggleFullScreen, - onCopyLink = onCopyLink, - onShare = onShare, - onDownload = onDownload, - usernameProgress = usernameProgress, - usernameMinimumBalance = usernameMinimumBalance, - onClaimUsername = onClaimUsername, - profileTutorial = profileTutorial, - onSetDisplayName = onClaim, - onSetProfilePicture = onSetProfilePicture, - onSetMinimumTip = onSetMinimumTip, + SettingsGear( + modifier = Modifier.align(Alignment.TopEnd), + onClick = onSettings, + hazeState = hazeState, ) } } /** - * The claimed card and everything that hangs off it. - * - * The caller drives the full-screen state: it scales the card ([cardScale]) and draws it out of its - * slot towards the middle of the display ([cardShift], off the slot position reported by - * [onCardSlotPositioned]). It also hands down [slideAway] — the fade-and-slide every non-card - * element shares — plus [expansion] for the caption, which iOS fades in place rather than sliding. - * - * All four arrive as lambdas so they are read inside the graphics layers that use them, off the - * composition. Read as values, the whole page would recompose on every frame of the animation. + * The settings gear, clear of the status bar. Over the cover it frosts the picture behind it + * ([hazeState]); with no cover to frost (an account still loading or unnamed) it falls back to the + * flat translucent fill. */ -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun ClaimedTipCard( - card: Scannable.TipCard, - link: String?, - enabled: Boolean, - expansion: () -> Float, - slideAway: Modifier, - cardScale: () -> Float, - cardShift: () -> Float, - onCardSlotPositioned: (Float) -> Unit, - onToggleFullScreen: () -> Unit, - onCopyLink: () -> Unit, - onShare: () -> Unit, - onDownload: () -> Unit, - usernameProgress: UsernameProgress?, - usernameMinimumBalance: String, - onClaimUsername: () -> Unit, - profileTutorial: List?, - onSetDisplayName: () -> Unit, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> Unit, +private fun SettingsGear( + onClick: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, ) { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - // Static display (no camera behind the card): render it opaque at the design's flattened - // colour rather than the translucent frosted fill. Figma flattens the card to rgb(16,16,17). - CompositionLocalProvider( - LocalTipCardColor provides Color(0xFF101011), - LocalTipCardBaseAlpha provides 1f, - ) { - Box( - modifier = Modifier - // The card pads itself off the status bar for the full-screen overlay; here the - // list's content padding already owns that clearance, so consume the inset - // rather than paying it twice. - .consumeWindowInsets(WindowInsets.statusBarsIgnoringVisibility) - // The slot is the card at rest, pinned: the card grows by scaling out of it, - // so the slot's centre — which cardShift measures from — has to hold still. - .size(YouCardWidth, YouCardWidth * TipCardAspectRatio) - .onGloballyPositioned { onCardSlotPositioned(it.boundsInRoot().center.y) }, - contentAlignment = Alignment.Center, - ) { - Box( - modifier = Modifier - // Measured unbounded so the card can be drawn at its full width inside the - // smaller slot; the slot's constraints would otherwise squeeze it back down - // to the resting size and there would be nothing to scale up to. - .wrapContentSize(unbounded = true) - // Ahead of the gesture modifiers, so the drag and the tap travel with the - // card rather than staying behind at the slot it left. Both reads happen - // here rather than in composition: the layer re-runs this block by itself - // when they change, which is a render-node transform and no relayout. - .graphicsLayer { - val scale = cardScale() - scaleX = scale - scaleY = scale - translationY = cardShift() - } - .noRippleClickable { onToggleFullScreen() }, - ) { - ScannableRenderer(scannable = card, tipCardWidth = FullScreenCardWidth) - } - } - } - - Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) - - // The caption belongs to the card, so it fades where it stands instead of sliding off with - // the rest of the page — and it is gone well before the card is over it. The caption is a - // later sibling than the card and so paints on top of it, while the card grows and travels - // down across this very spot; faded over the whole expansion it would still be legible at - // the point it ends up printed across the card's face. See [CaptionFadeTravel]. - FullScreenToggle( - label = stringResource(R.string.action_viewFullScreen), - chevronRotation = 0f, - modifier = Modifier - .graphicsLayer { alpha = (1f - expansion() / CaptionFadeTravel).coerceIn(0f, 1f) } - .noRippleClickable { onToggleFullScreen() }, - ) - - // Everything under the card gets out of the way so the card can own the display. - Column( - modifier = slideAway.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(Modifier.height(CodeTheme.dimens.grid.x6)) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = CodeTheme.dimens.grid.x5), - verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - // Node 9641:17031 puts the checklist directly under the caption, above the - // link row. Null while the profile is unresolved so it never draws against a - // guess. - if (profileTutorial != null) { - ProfileTutorial( - items = profileTutorial, - onSetDisplayName = onSetDisplayName, - onSetProfilePicture = onSetProfilePicture, - onSetMinimumTip = onSetMinimumTip, - ) - - // Node 9641:17048 separates the checklist from the link row. The column - // already spaces siblings by 10dp; the rest of the 20dp gap on each side is - // the divider's own padding. - HorizontalDivider( - modifier = Modifier.padding(vertical = CodeTheme.dimens.grid.x2), - color = CodeTheme.colors.divider, - thickness = CodeTheme.dimens.border, - ) - } - - if (link != null) { - TipLinkRow(link = link, enabled = enabled, onCopy = onCopyLink) - } + ProfileBarButton( + icon = R.drawable.ic_settings_outline, + contentDescription = stringResource(CoreR.string.title_settings), + onClick = onClick, + modifier = modifier, + hazeState = hazeState, + testTag = "you-settings", + ) +} - Row( - modifier = Modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x18), - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), - ) { - ShareTile( - modifier = Modifier.weight(1f), - icon = R.drawable.ic_share_os, - label = stringResource(R.string.action_share), - enabled = enabled, - onClick = onShare, - ) - ShareTile( - modifier = Modifier.weight(1f), - icon = R.drawable.ic_file_download, - label = stringResource(R.string.action_download), - enabled = enabled, - onClick = onDownload, - ) - } +/** + * The resting card's width (node 9278:7301: 241.636), kept as the fraction of a 402-wide display it + * was drawn at rather than the dp it happened to measure on it. + */ +private const val YouCardWidthFraction = 0.60f - // Only under a claimed card: an account with no display name is already being asked - // for one, and a second nudge under the blurred stand-in isn't in the design. Gone - // entirely once a handle exists — the caller nulls it out. - if (usernameProgress != null) { - UsernameProgressCard( - progress = usernameProgress, - minimumBalance = usernameMinimumBalance, - onClick = onClaimUsername, - ) - } - } +private val YouCardWidth: Dp + @Composable get() = CodeTheme.dimens.screenWidth * YouCardWidthFraction - Spacer(Modifier.height(CodeTheme.dimens.grid.x4)) - } - } -} +/** Gap between the header and the unclaimed stand-in (iOS 24). */ +private val UnclaimedTopSpacing: Dp + @Composable get() = CodeTheme.dimens.staticGrid.x5 /** * What the "You" tab shows before the account has a display name: the card it *would* have, blurred @@ -627,8 +272,8 @@ private fun ClaimedTipCard( * * The stand-in is the account's real scannable payload drawn over an unnamed profile, with the * card's own fill turned off so the 8% ground shows through — the same construction iOS uses. It is - * decoration: not tappable, not expandable, not shareable, and the tip link and Share / Download - * tiles are absent entirely, because there is nothing yet to link to or share. + * decoration: not tappable, not expandable, not shareable. It takes the stats card's place under the + * header, and the header drops its Share button, because there is nothing yet to share. * * [blurEnabled] is haze's own API-31 gate, surfaced so a preview can render what an API 29/30 * device draws (see `Preview_UnclaimedTipCardPrompt_NoBlur`). Leave it at the default in app code. @@ -638,11 +283,7 @@ private fun ClaimedTipCard( private fun UnclaimedTipCardPrompt( placeholder: Scannable.TipCard?, cardWidth: Dp, - enabled: Boolean, onClaim: () -> Unit, - profileTutorial: List?, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> Unit, blurEnabled: Boolean = HazeBlurDefaults.isBlurEnabledByDefault(), ) { val shape = RoundedCornerShape(cardWidth * TipCardCornerFraction) @@ -700,7 +341,7 @@ private fun UnclaimedTipCardPrompt( } CompositionLocalProvider( - LocalTipCardColor provides Color(0xFF101011), + LocalTipCardColor provides TipCardFlattened, // Fill off, so the placeholder ground behind it is what's frosted, not an opaque card. LocalTipCardBaseAlpha provides 0f, ) { @@ -743,7 +384,7 @@ private fun UnclaimedTipCardPrompt( .padding(top = CodeTheme.dimens.grid.x4) .clip(CircleShape) .background(CodeTheme.colors.textMain) - .clickable(enabled = enabled, onClick = onClaim) + .clickable(onClick = onClaim) .padding( horizontal = CodeTheme.dimens.grid.x5, vertical = CodeTheme.dimens.grid.x2, @@ -755,47 +396,10 @@ private fun UnclaimedTipCardPrompt( } } - // No link row or Share / Download to sit above here, so the checklist goes straight under - // the stand-in, with the display-name step first. - if (profileTutorial != null) { - ProfileTutorial( - modifier = Modifier - .padding(top = CodeTheme.dimens.grid.x6) - .padding(horizontal = CodeTheme.dimens.grid.x5), - items = profileTutorial, - // The same destination as "Start Receiving Tips": the name is what claims the card. - onSetDisplayName = onClaim, - onSetProfilePicture = onSetProfilePicture, - onSetMinimumTip = onSetMinimumTip, - ) - } - Spacer(Modifier.height(UnclaimedRowsGap)) } } -/** The "Finish Your Profile" checklist, drawn under whichever card the tab is showing. */ -@Composable -private fun ProfileTutorial( - items: List, - onSetDisplayName: () -> Unit, - onSetProfilePicture: () -> Unit, - onSetMinimumTip: () -> Unit, - modifier: Modifier = Modifier, -) { - NewUserTutorial( - modifier = modifier.fillMaxWidth(), - title = stringResource(R.string.title_finishYourProfile), - items = items, - ) { item -> - when (item) { - is TutorialItem.DisplayName -> onSetDisplayName() - is TutorialItem.ProfilePicture -> onSetProfilePicture() - is TutorialItem.MinimumTip -> onSetMinimumTip() - } - } -} - /** The tip card's height-to-width proportion and corner radius, mirrored from `TipCard`. */ private const val TipCardAspectRatio = 333f / 269f private const val TipCardCornerFraction = 0.08f @@ -812,156 +416,12 @@ private val PromptInset: Dp @Composable get() = CodeTheme.dimens.grid.x2 /** - * Gap between the unclaimed stand-in and the first settings row. Wider than the claimed card's 19, - * because the claimed card pays part of its clearance in the Share / Download tiles that the - * unclaimed state doesn't draw (iOS `YouScreen`: `.padding(.top, displayName == nil ? 48 : 19)`). + * Gap below the unclaimed stand-in. Wider than the claimed card's 19, because the claimed + * card pays part of its clearance in the action buttons that the unclaimed state doesn't draw (iOS `YouScreen`: `.padding(.top, displayName == nil ? 48 : 19)`). */ private val UnclaimedRowsGap: Dp @Composable get() = CodeTheme.dimens.grid.x10 -/** - * The label + chevron that toggles the card's full-screen state — "Full Screen" pointing down under - * the resting card (node 9276:4634), "Close" pointing up at the foot of the expanded one - * (node 9277:121410). One glyph, flipped, so the two read as the same control. - */ -@Composable -private fun FullScreenToggle( - label: String, - chevronRotation: Float, - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier, - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Text( - text = label, - style = CodeTheme.typography.textSmall, - color = White50, - ) - Icon( - modifier = Modifier - .size(16.dp) - .rotate(chevronRotation), - painter = painterResource(R.drawable.ic_chevron_down_medium), - contentDescription = null, - tint = White50, - ) - } -} - -/** The tip link, tap-to-copy (node 9276:4748). Shown short — the full URL goes to the clipboard. */ -@Composable -private fun TipLinkRow(link: String, enabled: Boolean, onCopy: () -> Unit) { - // Bumped rather than latched so a second tap restarts the hold instead of being swallowed. - var copyToken by remember { mutableIntStateOf(0) } - val copied = copyToken > 0 - - LaunchedEffect(copyToken) { - if (copyToken > 0) { - delay(CopyConfirmationMillis) - copyToken = 0 - } - } - - Row( - modifier = Modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x8) - .clip(CodeTheme.shapes.extraSmall) - .background(White05) - .clickable(enabled = enabled) { - onCopy() - copyToken++ - } - .padding(horizontal = CodeTheme.dimens.grid.x3), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Row( - modifier = Modifier.weight(1f, fill = false), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1), - ) { - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(R.drawable.ic_chain_link), - contentDescription = null, - tint = White, - ) - Text( - text = link.abbreviatedLink(), - style = CodeTheme.typography.textSmall.copy(fontSize = 15.sp), - color = White.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - // Confirms the copy landed, then hands the row back to the copy glyph (mirrors iOS - // TipCardLinkRow) — the clipboard gives no feedback of its own. - Crossfade( - targetState = copied, - animationSpec = tween(CopyIconFadeMillis, easing = EaseInOut), - label = "copyConfirmation", - ) { showCheck -> - Icon( - modifier = Modifier.size(20.dp), - painter = painterResource( - if (showCheck) R.drawable.ic_check_circle else R.drawable.ic_copy - ), - contentDescription = null, - tint = White, - ) - } - } -} - -/** How long the copy button holds the checkmark before reverting (iOS: 1.5s). */ -private const val CopyConfirmationMillis = 1_500L - -/** Cross-fade between the copy and confirmation glyphs (iOS: 0.15s ease-in-out). */ -private const val CopyIconFadeMillis = 150 - -/** - * One of the two square-ish actions under the link (node 9276:4756). The tile's height is fixed by - * the parent row and the arrangement centres its contents, so it takes no vertical padding of its - * own: 20dp of it on each side left the label a 14dp box for a 16dp line and clipped its descenders. - */ -@Composable -private fun ShareTile( - modifier: Modifier = Modifier, - icon: Int, - label: String, - enabled: Boolean, - onClick: () -> Unit, -) { - Column( - modifier = modifier - .fillMaxSize() - .clip(CodeTheme.shapes.extraSmall) - .background(White05) - .clickable(enabled = enabled) { onClick() }, - verticalArrangement = Arrangement.spacedBy( - CodeTheme.dimens.grid.x1, - Alignment.CenterVertically, - ), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Icon( - modifier = Modifier.size(28.dp), - painter = painterResource(icon), - contentDescription = null, - tint = White, - ) - Text( - text = label, - style = CodeTheme.typography.textSmall, - color = White50, - ) - } -} - private val PreviewCodeData = listOf( 0xA5, 0x3C, 0xD7, 0x8B, 0x14, 0xE9, 0x62, 0xF0, 0x4D, 0xB6, 0x29, 0x7A, 0xC3, 0x58, 0x91, 0xDE, @@ -978,12 +438,7 @@ private fun Preview_UnclaimedTipCardPrompt() { UnclaimedTipCardPrompt( placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty), cardWidth = YouCardWidth, - enabled = true, onClaim = {}, - // What a nameless account sees: the display-name step first, nothing done yet. - profileTutorial = profileTutorialItems(UserProfile.Empty), - onSetProfilePicture = {}, - onSetMinimumTip = {}, ) } @@ -999,11 +454,7 @@ private fun Preview_UnclaimedTipCardPrompt_NoBlur() { UnclaimedTipCardPrompt( placeholder = Scannable.TipCard(data = PreviewCodeData, user = UserProfile.Empty), cardWidth = YouCardWidth, - enabled = true, onClaim = {}, - profileTutorial = null, - onSetProfilePicture = {}, - onSetMinimumTip = {}, blurEnabled = false, ) } diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index 7596c6d7dc..67931d34f1 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -1,6 +1,5 @@ package com.flipcash.app.menu.internal -import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents @@ -10,28 +9,19 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep import com.flipcash.app.core.bill.Scannable -import com.flipcash.app.core.extensions.setText -import com.flipcash.app.core.share.TipCodeExportFormat -import com.flipcash.app.core.share.TipCodeExporter -import com.flipcash.app.core.ui.onboarding.TutorialItem -import com.flipcash.app.core.util.Linkify -import com.flipcash.app.core.toast.SystemToastController -import com.flipcash.app.core.tipping.TipCardOwner import com.flipcash.app.menu.internal.components.UsernameProgress import com.flipcash.app.funding.PurchaseMethodController -import com.flipcash.app.shareable.ShareSheetController -import com.flipcash.app.shareable.Shareable import com.flipcash.app.tokens.core.TotalBalanceProvider import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.features.menu.R -import com.flipcash.services.models.UserProfile import com.flipcash.services.user.AuthState +import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager +import com.flipcash.shared.payments.TipPaymentDelegate import com.flipcash.shared.tipping.TippingCoordinator import com.flipcash.libs.coroutines.DispatcherProvider import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.Fiat import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel @@ -40,10 +30,12 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach +import java.util.Locale import javax.inject.Inject /** @@ -57,16 +49,13 @@ private fun Fiat.formattedGate(): String = internal class MenuScreenViewModel @Inject constructor( userManager: UserManager, userFlags: UserFlagsCoordinator, - private val toastController: SystemToastController, dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, totalBalance: TotalBalanceProvider, analytics: FlipcashAnalytics, private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, - private val shareable: ShareSheetController, - private val clipboardManager: ClipboardManager, - private val tipCodeExporter: TipCodeExporter, + tipPayments: TipPaymentDelegate, private val resources: ResourceHelper, ) : BaseViewModel( @@ -75,46 +64,42 @@ internal class MenuScreenViewModel @Inject constructor( defaultDispatcher = dispatchers.Default, ) { data class State( - // The viewer's own tip card, shown at the top of the v2 "You" tab. - val tipCardState: TipCardState = TipCardState.Unknown, + // What the header draws: nothing yet, the claim prompt, or the viewer's own profile. + val profileState: ProfileState = ProfileState.Unknown, // The nudge toward claiming a `@handle`, or null when there is nothing to nudge about — a // handle already exists, or the account state hasn't resolved yet. val usernameProgress: UsernameProgress? = null, // The gate, formatted (e.g. `$100 USD`). Carried next to [usernameProgress] because both the // card's locked subtitle and the sheet behind its tap quote it. val usernameMinimumBalance: String = "", - // The "Finish Your Profile" checklist, or null while the profile is unresolved. Only ever - // drawn under a claimed card — see [ClaimedTipCard]. - val profileTutorial: List? = null, - ) { - /** The card to share, export or expand — only a claimed one qualifies. */ - val tipCard: Scannable.TipCard? - get() = (tipCardState as? TipCardState.Claimed)?.card - - /** The shareable URL for [tipCard]. Displayed abbreviated; copied in full. */ - val tipLink: String? - get() = (tipCardState as? TipCardState.Claimed)?.link - } + // What another user must send to open a chat with the viewer, formatted. Null until it + // resolves, which the stats card draws as a dash. + val minimumToChat: String? = null, + ) /** * What the "You" tab has to draw at the top of the page. * - * The three cases are deliberately distinct: `tipCard == null` used to mean both "we haven't - * resolved it yet" and "this account has no display name, so there is nothing to resolve", and - * the header drew nothing for either — leaving a nameless account with no card, no prompt, and - * no way to claim one from this tab. + * The cases are deliberately distinct: a missing profile used to mean both "we haven't resolved + * it yet" and "this account has no display name", and the header drew nothing for either — + * leaving a nameless account with no prompt and no way to claim a name from this tab. */ - sealed interface TipCardState { + sealed interface ProfileState { /** Still resolving (or signed out). Draw nothing rather than guessing. */ - data object Unknown : TipCardState + data object Unknown : ProfileState /** - * The account has no display name, so it has no card to show yet. [placeholder] is a real - * scannable stand-in drawn blurred behind the claim prompt; it is never shareable. + * The account has no display name yet. [placeholder] is a real scannable stand-in drawn + * blurred behind the claim prompt; it is never shareable. [profile] is whatever the server + * already holds (a cover, say), or null for an account it has never seen. */ - data class Unclaimed(val placeholder: Scannable.TipCard?) : TipCardState + data class Unclaimed( + val placeholder: Scannable.TipCard?, + val profile: UserProfile? = null, + ) : ProfileState - data class Claimed(val card: Scannable.TipCard, val link: String?) : TipCardState + /** The viewer's own profile, with [joined] already formatted for the stats card. */ + data class Named(val profile: UserProfile, val joined: String?) : ProfileState } sealed interface Event { @@ -128,27 +113,20 @@ internal class MenuScreenViewModel @Inject constructor( ) : Event data class OpenScreen(val screen: AppRoute) : Event - data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event + data class OnProfileStateChanged(val profileState: ProfileState) : Event data class OnUsernameProgressChanged( val progress: UsernameProgress?, val minimumBalance: String, ) : Event - data class OnProfileTutorialChanged(val items: List?) : Event - - /** The checklist's photo row — opens the photo step of the profile flow on its own. */ - data object SetProfilePicture : Event - - /** The checklist's minimum-tip row — opens the amount entry for the DM-init fee. */ - data object SetMinimumTip : Event + data class OnMinimumToChatChanged(val minimumToChat: String?) : Event /** The progress card's tap — claim a handle, or explain why it can't be claimed yet. */ data object ClaimUsername : Event /** The claim prompt's CTA — collect a display name so the account gets a real card. */ data object ClaimTipCard : Event - data object ShareTipCard : Event - data object CopyTipLink : Event - data object DownloadTipCard : Event - data class ExportTipCard(val format: TipCodeExportFormat) : Event + + /** The header's share button — opens the share sheet, but only for a profile that has a name. */ + data object ShareProfile : Event } init { @@ -160,9 +138,9 @@ internal class MenuScreenViewModel @Inject constructor( }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) - // Rebuild the viewer's own tip card whenever their profile becomes available/changes, so - // the v2 "You" tab can show it at the top. Warm the Sharesheet preview eagerly so it's ready - // by the time the user taps "Share as a Link" — but only for a card that can be shared. + // Rebuild what the header draws whenever the viewer's profile becomes available/changes. + // Warm the Sharesheet preview eagerly so it's ready by the time the user picks "Share + // Profile" — but only for a profile that can be shared. // // Gated on Ready: a named account restores its cached profile before auth completes, so // waiting here means it never flashes the claim prompt on the way in. @@ -171,37 +149,38 @@ internal class MenuScreenViewModel @Inject constructor( .map { it.userProfile } .distinctUntilChanged() .onEach { profile -> - if (profile?.displayName.isNullOrEmpty()) { - // No name means no card yet — the tab prompts to claim one instead. Built - // locally, so an account whose profile the server has never seen still gets it. + if (profile == null || profile.displayName.isEmpty()) { + // No name yet: the header still draws (cover, avatar, Edit Profile) with the claim + // prompt under it, as on iOS. The placeholder is built locally, so an account whose profile the server has never seen still + // gets it. dispatchEvent( - Event.OnTipCardStateChanged( - TipCardState.Unclaimed(tippingCoordinator.unclaimedTipCard()) + Event.OnProfileStateChanged( + ProfileState.Unclaimed(tippingCoordinator.unclaimedTipCard(), profile) ) ) } else { - tippingCoordinator.resolveTipCard().onSuccess { card -> - val userId = tippingCoordinator.currentUserId - dispatchEvent( - Event.OnTipCardStateChanged( - TipCardState.Claimed(card, tipCardLink(card.user, userId)) - ) + dispatchEvent( + Event.OnProfileStateChanged( + ProfileState.Named(profile, joinedLabel(profile.joinedAt, Locale.getDefault())) ) - userId?.let { tipCodePreviewCache.prepare(it, card) } + ) + tippingCoordinator.resolveTipCard().onSuccess { card -> + tippingCoordinator.currentUserId?.let { tipCodePreviewCache.prepare(it, card) } } } } .launchIn(viewModelScope) - // Gated on Ready for the same reason as the tip card: a named account restores its cached - // profile before auth completes, so the checklist would otherwise flash an outstanding - // photo step at someone who already has one. + // The fee is a Flow because it moves with the exchange rate and the region minimum, and it + // follows the profile because the viewer's own fee setting is part of the answer. userManager.state .filter { it.authState is AuthState.Ready } .map { it.userProfile } .distinctUntilChanged() - .map { profileTutorialItems(it) } - .onEach { dispatchEvent(Event.OnProfileTutorialChanged(it)) } + .flatMapLatest { tipPayments.startChattingFee(it) } + .map { it?.formatted() } + .distinctUntilChanged() + .onEach { dispatchEvent(Event.OnMinimumToChatChanged(it)) } .launchIn(viewModelScope) // The username nudge. Gated on Ready for the same reason as the tip card: a named account @@ -292,112 +271,17 @@ internal class MenuScreenViewModel @Inject constructor( .launchIn(viewModelScope) eventFlow - .filterIsInstance() - .onEach { - dispatchEvent( - Event.OpenScreen( - AppRoute.UpdateUserProfile( - origin = AppRoute.Tabs.Menu, - // The account already has a name and a card by the time this - // checklist is drawn, so the flow reduces to the one step. - steps = listOf(UpdateProfileStep.Photo), - ) - ) - ) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - dispatchEvent( - Event.OpenScreen( - AppRoute.UpdateUserProfile( - origin = AppRoute.Tabs.Menu, - steps = listOf(UpdateProfileStep.MinimumTip), - ) - ) - ) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { stateFlow.value.tipLink } - .onEach { link -> - // The row shows an abbreviated link; the clipboard gets the whole thing. - clipboardManager.setText( - text = link, - label = resources.getString(R.string.title_clipboardLabelTipCardLink), - ) - toastController.showToast(R.string.action_copied, replacePrevious = true) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .onEach { - BottomBarManager.showMessage( - title = resources.getString(R.string.title_downloadTipCardAs), - actions = downloadOptions(resources) { format -> - dispatchEvent(Event.ExportTipCard(format)) - }, - showCancel = false, - showScrim = true, - ) - } - .launchIn(viewModelScope) - - // Render the chosen format, then hand the file to the Sharesheet — Android has no - // permissionless "save to Photos", and the chooser already offers Files/Drive/Photos. - eventFlow - .filterIsInstance() - .mapNotNull { event -> stateFlow.value.tipCard?.let { it to event.format } } - .onEach { (card, format) -> - val export = tipCodeExporter.export(card, format) - if (export == null) { - BottomBarManager.showMessage( - title = resources.getString(R.string.error_title_tipCardExportFailed), - message = resources.getString(R.string.error_description_tipCardExportFailed), - ) - return@onEach - } - shareable.present( - Shareable.TipCodeImage( - export = export, - title = resources.getString(R.string.title_shareTipCode), - ) - ) - } - .launchIn(viewModelScope) - - eventFlow - .filterIsInstance() - .mapNotNull { tippingCoordinator.currentUserId } - .map { userId -> - val user = stateFlow.value.tipCard?.user - // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. - shareable.present( - Shareable.Profile( - userId = userId, - displayName = user?.displayName, - username = user?.username, - preview = tipCodePreviewCache.get(userId), - ) - ) - } + .filterIsInstance() + .mapNotNull { shareProfileRoute(stateFlow.value.profileState) } + .onEach { dispatchEvent(Event.OpenScreen(it)) } .launchIn(viewModelScope) } - /** The link the card shares itself with. Null only when there is no signed-in user to address. */ - private fun tipCardLink(profile: UserProfile, userId: ID?): String? = - userId?.let { Linkify.tipcard(TipCardOwner.preferringUsername(profile.username, it)) } - internal companion object { - private val updateStateForEvent: (Event) -> ((State) -> State) = { event -> + internal val updateStateForEvent: (Event) -> ((State) -> State) = { event -> when (event) { - is Event.OnTipCardStateChanged -> { state -> - state.copy(tipCardState = event.tipCardState) + is Event.OnProfileStateChanged -> { state -> + state.copy(profileState = event.profileState) } is Event.OnUsernameProgressChanged -> { state -> @@ -407,21 +291,23 @@ internal class MenuScreenViewModel @Inject constructor( ) } - is Event.OnProfileTutorialChanged -> { state -> - state.copy(profileTutorial = event.items) + is Event.OnMinimumToChatChanged -> { state -> + state.copy(minimumToChat = event.minimumToChat) } is Event.PresentDepositOptions, Event.ClaimTipCard, Event.ClaimUsername, - Event.SetProfilePicture, - Event.SetMinimumTip, - Event.ShareTipCard, - Event.CopyTipLink, - Event.DownloadTipCard, - is Event.ExportTipCard, + Event.ShareProfile, is Event.OpenScreen -> { state -> state } } } } } + +/** + * Where the header's share button goes: the share sheet, once the viewer has a profile worth + * sharing. An account with no name has no card or link to hand out, so the tap leads nowhere. + */ +internal fun shareProfileRoute(profileState: MenuScreenViewModel.ProfileState): AppRoute? = + if (profileState is MenuScreenViewModel.ProfileState.Named) AppRoute.Menu.ShareProfile else null diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt new file mode 100644 index 0000000000..e2828d467f --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileBarButton.kt @@ -0,0 +1,45 @@ +package com.flipcash.app.menu.internal + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.painterResource +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.CircularIconButton +import dev.chrisbanes.haze.HazeState + +/** + * The single trailing control the You tab and its profile card draw over their content: Settings on + * the tab, Download on the card. It sits in an untitled [AppBarWithTitle] so it lands where every + * other tab's trailing bar button does, and it frosts whatever is under it when given a [hazeState]. + */ +@Composable +internal fun ProfileBarButton( + @DrawableRes icon: Int, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + hazeState: HazeState? = null, + testTag: String? = null, +) { + AppBarWithTitle( + modifier = modifier, + endContent = { + CircularIconButton( + hazeState = hazeState, + onClick = onClick, + testTag = testTag, + ) { size -> + Icon( + painter = painterResource(icon), + contentDescription = contentDescription, + tint = Color.White, + modifier = Modifier.requiredSize(size), + ) + } + }, + ) +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt new file mode 100644 index 0000000000..8268261a32 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileCardViewModel.kt @@ -0,0 +1,71 @@ +package com.flipcash.app.menu.internal + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.core.share.TipCodeExportFormat +import com.flipcash.app.core.share.TipCodeExporter +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.shareable.Shareable +import com.flipcash.features.menu.R +import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** The viewer's own profile card, shown full screen and exportable as an image. */ +@HiltViewModel +internal class ProfileCardViewModel @Inject constructor( + private val tippingCoordinator: TippingCoordinator, + private val tipCodeExporter: TipCodeExporter, + private val shareable: ShareSheetController, + private val resources: ResourceHelper, +) : ViewModel() { + + /** Null until the card resolves. */ + var card: Scannable.TipCard? by mutableStateOf(null) + private set + + init { + viewModelScope.launch { + tippingCoordinator.resolveTipCard().onSuccess { card = it } + } + } + + /** Asks which format to export, then renders it. */ + fun download() { + BottomBarManager.showMessage( + title = resources.getString(R.string.title_downloadTipCardAs), + actions = downloadOptions(resources) { format -> export(format) }, + showCancel = false, + showScrim = true, + ) + } + + // Render the chosen format, then hand the file to the Sharesheet — Android has no + // permissionless "save to Photos", and the chooser already offers Files/Drive/Photos. + private fun export(format: TipCodeExportFormat) { + val card = card ?: return + viewModelScope.launch { + val export = tipCodeExporter.export(card, format) + if (export == null) { + BottomBarManager.showMessage( + title = resources.getString(R.string.error_title_tipCardExportFailed), + message = resources.getString(R.string.error_description_tipCardExportFailed), + ) + return@launch + } + shareable.present( + Shareable.TipCodeImage( + export = export, + title = resources.getString(R.string.title_shareTipCode), + ) + ) + } + } +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt new file mode 100644 index 0000000000..56946d8594 --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileShareViewModel.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.menu.internal + +import android.content.ClipboardManager +import androidx.lifecycle.ViewModel +import com.flipcash.app.bills.share.TipCodePreviewCache +import com.flipcash.app.core.extensions.setText +import com.flipcash.app.core.tipping.TipCardOwner +import com.flipcash.app.core.toast.SystemToastController +import com.flipcash.app.core.util.Linkify +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.shareable.Shareable +import com.flipcash.features.menu.R +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.util.resources.ResourceHelper +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import javax.inject.Inject + +/** + * What the share sheet's rows do for the viewer's own profile. The sheet only chooses; the + * navigation host runs one of these once the sheet has finished leaving. + */ +@HiltViewModel +internal class ProfileShareViewModel @Inject constructor( + private val userManager: UserManager, + private val tippingCoordinator: TippingCoordinator, + private val tipCodePreviewCache: TipCodePreviewCache, + private val shareable: ShareSheetController, + private val clipboardManager: ClipboardManager, + private val toastController: SystemToastController, + private val resources: ResourceHelper, +) : ViewModel() { + + // Not viewModelScope: the actions run after the sheet has finished leaving, and this view model + // is cleared with the sheet's entry before then. Each action is short and holds nothing but + // singletons, so there is nothing to leak by letting it finish. + private val actionScope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + + /** The profile being shared, read once when the sheet opens. */ + val profile: UserProfile? + get() = userManager.state.value.userProfile + + /** The link a profile is shared by. Null only when there is no signed-in user to address. */ + private val link: String? + get() = tippingCoordinator.currentUserId?.let { userId -> + Linkify.tipcard(TipCardOwner.preferringUsername(profile?.username, userId)) + } + + /** Hands the profile link, with its card preview if ready, to the system share sheet. */ + fun share() { + val userId = tippingCoordinator.currentUserId ?: return + // Attach the eagerly-rendered preview if it's ready; null shares the URL alone. + actionScope.launch { + shareable.present( + Shareable.Profile( + userId = userId, + displayName = profile?.displayName, + username = profile?.username, + preview = tipCodePreviewCache.get(userId), + ) + ) + } + } + + fun copyLink() { + val link = link ?: return + clipboardManager.setText( + text = link, + label = resources.getString(R.string.title_clipboardLabelTipCardLink), + ) + toastController.showToast(R.string.action_copied, replacePrevious = true) + } +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt deleted file mode 100644 index bfafd690a3..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/ProfileTutorial.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.flipcash.app.menu.internal - -import com.flipcash.app.core.ui.onboarding.TutorialItem -import com.flipcash.services.models.UserProfile - -/** - * The "Finish Your Profile" checklist for the "You" tab. - * - * Null while the profile is unresolved, so the card is never drawn against a guess — an account - * that already has a photo would otherwise flash an outstanding step on the way in. Null again once - * every step is done: a checklist with nothing left to do is just a row of ticks. - * - * Every step reads straight off the profile, so a step completed elsewhere — My Account's own - * Minimum Tip row, say — closes here too. An account that already has a name sees that step ticked - * rather than dropped, so the list keeps its length until the whole thing goes away. - */ -internal fun profileTutorialItems(profile: UserProfile?): List? { - profile ?: return null - val items = listOf( - // The same test the tab uses to choose the claimed card over the blurred stand-in. - TutorialItem.DisplayName(isCompleted = profile.displayName.isNotEmpty()), - TutorialItem.ProfilePicture(isCompleted = profile.profilePicture != null), - TutorialItem.MinimumTip(isCompleted = profile.minDmChatInitFee != null), - ) - return items.takeUnless { steps -> steps.all { it.isCompleted } } -} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt deleted file mode 100644 index 4dd06b4f43..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/TipCardExpansion.kt +++ /dev/null @@ -1,213 +0,0 @@ -package com.flipcash.app.menu.internal - -import androidx.compose.animation.core.Animatable -import androidx.compose.animation.core.AnimationSpec -import androidx.compose.animation.core.spring -import androidx.compose.runtime.Composable -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.unit.dp -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.launch -import kotlin.math.sign - -/** - * The "You" tab's full-screen state, as one continuous 0..1 progress rather than a boolean. - * - * Everything the expansion touches — the card's width, how far it has travelled out of its slot, - * the page fading away beneath it, the Close row — is a reading of [progress], so a gesture can - * park the whole page part-way through the transition and hand it back where it found it. The tap - * affordances just drive the same progress to an end with a spring. - * - * [isExpanded] is the *intent*, not the position: it flips the moment a direction is committed to - * (tap, or the release of a swipe) and stays put for the length of a drag, so the tab bar and the - * list's scrolling don't flicker in and out as the finger crosses the half-way mark. - */ -@Stable -internal class TipCardExpansion(private val scope: CoroutineScope) { - - private val animatable = Animatable(0f) - - private val overdragAnimatable = Animatable(0f) - - /** 0 at rest, 1 full screen; anywhere in between while dragging or springing. */ - val progress: Float get() = animatable.value - - /** - * How far a downward drag has pushed the card past full screen, in the same units as - * [progress] — resisted by [OverdragResistance], and deliberately not part of the progress - * itself. Down is where expanding already put the card, so there is nothing there to scrub: - * only the card's position gives, and its size and everything reading the progress hold still. - */ - val overdrag: Float get() = overdragAnimatable.value - - /** - * The part of the drag that has run off the end, before resistance. Kept unresisted so the - * finger has to give all of it back before the card starts moving home again — the alternative - * is a card that leaves the end as soon as the finger turns around, a quarter of the way behind - * where it was pushed. - */ - private var overflow = 0f - - /** The recogniser's slack, waiting for the first delta to hand it back to. See [startDrag]. */ - private var pendingSlack = 0f - - var isExpanded by mutableStateOf(false) - private set - - fun toggle() { - if (isExpanded) collapse() else expand() - } - - fun expand(velocity: Float = 0f, spec: AnimationSpec = ExpansionSpring) { - isExpanded = true - animate(target = 1f, velocity = velocity, spec = spec) - } - - fun collapse(velocity: Float = 0f) { - isExpanded = false - animate(target = 0f, velocity = velocity, spec = ExpansionSpring) - } - - /** - * Opens a drag, with the [slack] the recogniser swallowed getting there — touch slop, in - * progress units. It is handed back on the first delta so the card picks up from where it - * stands rather than jumping that distance the moment the gesture is recognised. iOS hands back - * its own 10pt activation distance for the same reason. - */ - fun startDrag(slack: Float) { - pendingSlack = slack - overflow = 0f - } - - /** - * Moves the card by [deltaProgress] under the finger, cancelling whatever spring was running. - * - * Pulled up past its slot the card simply stops: it has arrived, and a finger that has run out - * of card should feel the end. Pushed down past full screen it gives a little instead — see - * [overdrag] — because there the card has nowhere to go but is still being asked to move. - */ - fun dragBy(deltaProgress: Float) { - val delta = deltaProgress + pendingSlack * sign(deltaProgress) - if (deltaProgress != 0f) pendingSlack = 0f - scope.launch { - val moved = animatable.value + overflow + delta - animatable.snapTo(moved.coerceIn(0f, 1f)) - overflow = (moved - 1f).coerceAtLeast(0f) - overdragAnimatable.snapTo(overflow * OverdragResistance) - } - } - - /** Springs to whichever end [settlesExpanded] picks, carrying the release velocity into it. */ - fun settle(velocity: Float, flingThreshold: Float) { - overflow = 0f - pendingSlack = 0f - - val staysExpanded = settlesExpanded(animatable.value, velocity, flingThreshold) - // A pull that fell short has a short way back, so it takes the short spring; one that - // committed is running the whole transition and takes the transition's own. - val spec = if (staysExpanded) ReturnSpring else ExpansionSpring - - scope.launch { overdragAnimatable.animateTo(0f, spec) } - if (staysExpanded) expand(velocity, spec) else collapse(velocity) - } - - private fun animate(target: Float, velocity: Float, spec: AnimationSpec) { - scope.launch { - animatable.animateTo( - targetValue = target, - animationSpec = spec, - initialVelocity = velocity.coerceIn(-MaxSettleVelocity, MaxSettleVelocity), - ) - } - } -} - -@Composable -internal fun rememberTipCardExpansion(): TipCardExpansion { - val scope = rememberCoroutineScope() - return remember(scope) { TipCardExpansion(scope) } -} - -/** - * Where a released swipe lands: a flick wins on velocity alone, however far it got, and anything - * slower goes on whether the pull covered enough of the way home ([CollapseThreshold]). - * - * [velocity] and [flingThreshold] are both in progress-per-second, so the caller divides the - * gesture's pixel velocity by the distance the card actually has left to travel — a flick means - * the same thing on a tall display as on a short one. - */ -internal fun settlesExpanded(progress: Float, velocity: Float, flingThreshold: Float): Boolean = - when { - velocity <= -flingThreshold -> false - velocity >= flingThreshold -> true - else -> progress > 1f - CollapseThreshold - } - -/** - * How much of the card's travel home a pull has to cover for the release to finish the job, as - * iOS's `collapseThreshold` does. - * - * Well short of half, because the two ends are not equally likely: the card is only ever dragged - * from one of them, by someone who has already decided to put it away. Asking for half of the - * display's height before that counts made the card feel like it was resisting. - */ -private const val CollapseThreshold = 0.3f - -/** - * The fraction of a downward drag the card follows past full screen. Down is where expanding - * already took the card, so the pull has nowhere to take it and gives only enough to show that the - * drag is being felt. iOS's `overdragResistance`. - */ -private const val OverdragResistance = 0.25f - -/** - * How far a swipe must be moving at release to decide the outcome on its own, regardless of how far - * it travelled. Material's own swipe threshold — low enough that a flick of the card is enough, - * high enough that letting go of a slow drag doesn't count as one. - */ -internal val MinFlingVelocity = 125.dp - -/** - * The floor on the drag's travel distance, for the frames before the card's slot has been measured - * (and for the pathological case of a display too short to move the card at all). Without it a drag - * would divide by a travel of zero and snap the card shut on the first pixel. - */ -internal val MinDragTravel = 120.dp - -/** - * The whole expansion — card size, card position, the content sliding away, the Close row — runs on - * one spring, as iOS does: `.spring(response: 0.45, dampingFraction: 0.85)`. SwiftUI's `response` is - * the undamped period, so the equivalent Compose stiffness is `(2 * PI / 0.45) ^ 2`. - */ -private val ExpansionSpring = spring(dampingRatio = 0.85f, stiffness = 195f) - -/** - * Puts a pull that fell short back where it started — iOS's `settle`, `.spring(response: 0.3, - * dampingFraction: 0.85)`. Shorter than [ExpansionSpring] because the card has barely moved, and - * spending the full transition on a few dp of travel reads as a stall rather than a return. - */ -private val ReturnSpring = spring(dampingRatio = 0.85f, stiffness = 439f) - -/** - * The ceiling on the release velocity a settle carries into its spring, in progress-per-second — - * which is really a ceiling on how far the card bounces past the end it landed on, since that is - * what an underdamped spring does with speed it is handed. - * - * Not clamped to no overshoot at all: the bounce is the card arriving somewhere and settling into - * it, and a flick that stops dead reads as a dropped frame. What it can't be is the ~18% of the - * travel an uncapped flick paid for — a card lifted clear out of its slot and up under the status - * bar, taking the page with it. This cap costs at most 5.6% (measured, across every release point), - * which is a bounce of about 10dp. [TipCardOvershootTest] holds both halves: some, and not much. - * - * It also floors how quickly a settle can be over. Uncapped, the hardest flick collapsed the card - * in three frames, which is less a transition than a cut. - * - * iOS never needs the cap because it never carries the gesture's velocity into the spring at all: - * its settle starts from a standstill, where this damping ratio overshoots half a percent. - */ -private const val MaxSettleVelocity = 4f diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt new file mode 100644 index 0000000000..3fb355c78c --- /dev/null +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgress.kt @@ -0,0 +1,16 @@ +package com.flipcash.app.menu.internal.components + +/** + * How close the account is to being allowed a `@handle`. Null in the state once a handle the user + * picked exists; otherwise it decides whether the claim link opens the entry screen or states the + * minimum balance. + */ +internal sealed interface UsernameProgress { + /** + * @param fraction how much of the minimum the balance covers, `0f..1f`. + * @param remaining the shortfall, already formatted for display (e.g. `$12.50 USD`). + */ + data class Locked(val fraction: Float, val remaining: String) : UsernameProgress + + data object Unlocked : UsernameProgress +} diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt deleted file mode 100644 index 8fd9e69558..0000000000 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/components/UsernameProgressCard.kt +++ /dev/null @@ -1,190 +0,0 @@ -package com.flipcash.app.menu.internal.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -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.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.Icon -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.tooling.preview.PreviewWrapper -import androidx.compose.ui.unit.dp -import com.flipcash.app.theme.FlipcashThemeWrapper -import com.flipcash.core.R -import com.getcode.theme.CodeTheme -import com.getcode.theme.SystemGreen -import com.getcode.theme.White -import com.getcode.theme.White05 -import com.getcode.theme.White10 -import com.getcode.theme.White50 -import com.getcode.theme.extraSmall - -/** - * How close the account is to being allowed a `@handle`, and — once it is — the way in. - * - * Locked and [Unlocked] are the design's two variants (nodes 9536:4336 and 9537:1845) rather than a - * bag of nullable fields, so a full bar can never render next to an amount still to go. - */ -internal sealed interface UsernameProgress { - /** - * @param fraction how much of the minimum the balance covers, `0f..1f`. - * @param remaining the shortfall, already formatted for display (e.g. `$12.50 USD`). - */ - data class Locked(val fraction: Float, val remaining: String) : UsernameProgress - - data object Unlocked : UsernameProgress -} - -/** - * Nodes 9536:4336 / 9537:1845 — the "You" tab's nudge toward claiming a `@handle`, sitting under the - * Share / Download tiles in the same 88dp skin. - * - * Tappable in both states: below the minimum the tap is what surfaces the "Minimum Balance Required" - * sheet, which is the only place the rule is spelled out. The caller decides that, and also decides - * whether the card renders at all — it is gone once a handle exists. - * - * [minimumBalance] is the formatted threshold (e.g. `$100 USD`), interpolated into the locked - * subtitle; it comes from the same `usernameMinBalance` flag the entry screen's rejection dialog - * reads, so the two never quote different numbers. - */ -@Composable -internal fun UsernameProgressCard( - progress: UsernameProgress, - minimumBalance: String, - modifier: Modifier = Modifier, - onClick: () -> Unit, -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x18) - .clip(CodeTheme.shapes.extraSmall) - .background(White05) - .clickable { onClick() } - .padding(horizontal = CodeTheme.dimens.grid.x3), - ) { - Column(modifier = Modifier.padding(top = CodeTheme.dimens.grid.x2)) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(R.string.title_usernameUpsell), - style = CodeTheme.typography.textSmall, - color = White, - ) - when (progress) { - is UsernameProgress.Locked -> Text( - text = stringResource(R.string.label_usernameAmountToGo, progress.remaining), - style = CodeTheme.typography.textSmall, - color = White50, - ) - - // The affordance only appears once tapping it leads somewhere other than a - // rejection. - UsernameProgress.Unlocked -> Icon( - modifier = Modifier.size(20.dp), - painter = painterResource(R.drawable.ic_chevron_right), - contentDescription = null, - tint = White50, - ) - } - } - Text( - modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), - text = when (progress) { - is UsernameProgress.Locked -> stringResource( - R.string.subtitle_usernameUpsellLocked, - minimumBalance, - ) - - UsernameProgress.Unlocked -> - stringResource(R.string.subtitle_usernameUpsellUnlocked) - }, - style = CodeTheme.typography.caption, - color = White50, - ) - } - - UsernameProgressBar( - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(bottom = CodeTheme.dimens.grid.x3), - fraction = when (progress) { - is UsernameProgress.Locked -> progress.fraction - UsernameProgress.Unlocked -> 1f - }, - // Green only on the full bar: it reads as "done", which a partial bar isn't. - color = when (progress) { - is UsernameProgress.Locked -> White - UsernameProgress.Unlocked -> SystemGreen - }, - ) - } -} - -@Composable -private fun UsernameProgressBar( - fraction: Float, - color: Color, - modifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .fillMaxWidth() - .height(CodeTheme.dimens.grid.x1) - .clip(CircleShape) - .background(White10), - ) { - Box( - modifier = Modifier - .fillMaxWidth(fraction.coerceIn(0f, 1f)) - .fillMaxHeight() - .clip(CircleShape) - .background(color), - ) - } -} - -@Preview(name = "Below the minimum") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun Preview_UsernameProgressCard_Locked() { - Box(modifier = Modifier.fillMaxSize().padding(CodeTheme.dimens.inset)) { - UsernameProgressCard( - progress = UsernameProgress.Locked(fraction = 0.84f, remaining = "$12.50 USD"), - minimumBalance = "$100 USD", - onClick = {}, - ) - } -} - -@Preview(name = "Minimum met") -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun Preview_UsernameProgressCard_Unlocked() { - Box(modifier = Modifier.fillMaxSize().padding(CodeTheme.dimens.inset)) { - UsernameProgressCard( - progress = UsernameProgress.Unlocked, - minimumBalance = "$100 USD", - onClick = {}, - ) - } -} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt new file mode 100644 index 0000000000..96cb8967c3 --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/JoinedLabelTest.kt @@ -0,0 +1,36 @@ +package com.flipcash.app.menu.internal + +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import java.time.ZoneOffset +import java.util.Locale +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +// Robolectric because the pattern comes from android.text.format.DateFormat, which a plain JVM +// test only has as a stub. +@RunWith(RobolectricTestRunner::class) +@Config(manifest = Config.NONE) +class JoinedLabelTest { + + // 2026-10-06T12:00:00Z + private val joinedAt = Instant.fromEpochSeconds(1_791_288_000) + + @Test + fun `joined is the full month and year`() { + assertEquals("October 2026", joinedLabel(joinedAt, Locale.US, ZoneOffset.UTC)) + } + + @Test + fun `joined follows the locale`() { + assertEquals("octubre de 2026", joinedLabel(joinedAt, Locale.forLanguageTag("es"), ZoneOffset.UTC)) + } + + @Test + fun `no join date means no label`() { + assertNull(joinedLabel(null, Locale.US, ZoneOffset.UTC)) + } +} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt new file mode 100644 index 0000000000..4609a8179a --- /dev/null +++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/OwnProfileStateTest.kt @@ -0,0 +1,36 @@ +package com.flipcash.app.menu.internal + +import com.flipcash.app.core.AppRoute +import com.flipcash.services.models.UserProfile +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class OwnProfileStateTest { + + private val reduce = MenuScreenViewModel.updateStateForEvent + + @Test + fun `the resolved fee lands in state and clears when it goes away`() { + val withFee = reduce(MenuScreenViewModel.Event.OnMinimumToChatChanged("$1.25"))(MenuScreenViewModel.State()) + assertEquals("$1.25", withFee.minimumToChat) + + val cleared = reduce(MenuScreenViewModel.Event.OnMinimumToChatChanged(null))(withFee) + assertNull(cleared.minimumToChat) + } + + @Test + fun `a named profile carries its joined label`() { + val named = MenuScreenViewModel.ProfileState.Named(UserProfile.Empty, joined = "October 2026") + val state = reduce(MenuScreenViewModel.Event.OnProfileStateChanged(named))(MenuScreenViewModel.State()) + assertEquals(named, state.profileState) + } + + @Test + fun `the share sheet opens only for a named profile`() { + val named = MenuScreenViewModel.ProfileState.Named(UserProfile.Empty, joined = null) + assertEquals(AppRoute.Menu.ShareProfile, shareProfileRoute(named)) + assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unknown)) + assertNull(shareProfileRoute(MenuScreenViewModel.ProfileState.Unclaimed(placeholder = null))) + } +} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt deleted file mode 100644 index 2a4766400d..0000000000 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/ProfileTutorialTest.kt +++ /dev/null @@ -1,90 +0,0 @@ -package com.flipcash.app.menu.internal - -import com.flipcash.app.core.ui.onboarding.TutorialItem -import com.flipcash.services.models.UserProfile -import com.flipcash.services.models.chat.MediaItem -import com.getcode.opencode.model.financial.Fiat -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class ProfileTutorialTest { - - // MediaItem is a plain data class over a rendition list, so an empty one stands in for - // "a picture is set" without needing a mocking library in this module. - private val anyPicture = MediaItem(renditions = emptyList()) - - private fun profile( - name: String = "Brandon", - picture: MediaItem? = null, - minimumTip: Fiat? = null, - ) = UserProfile( - displayName = name, - socialAccounts = emptyList(), - phoneNumber = null, - email = null, - profilePicture = picture, - minDmChatInitFee = minimumTip, - ) - - @Test - fun `an unresolved profile has no checklist`() { - assertNull(profileTutorialItems(profile = null)) - } - - @Test - fun `the steps run display name, picture, minimum tip`() { - val items = profileTutorialItems(profile(name = "")) - assertEquals( - listOf( - TutorialItem.DisplayName::class, - TutorialItem.ProfilePicture::class, - TutorialItem.MinimumTip::class, - ), - items?.map { it::class }, - ) - } - - @Test - fun `a nameless profile leaves every step outstanding`() { - val items = profileTutorialItems(profile(name = "")) - assertTrue(items!!.none { it.isCompleted }) - } - - @Test - fun `a named profile shows the display name step ticked rather than dropping it`() { - val items = profileTutorialItems(profile()) - assertEquals(3, items?.size) - assertEquals(1, items!!.count { it.isCompleted }) - assertTrue(items.first { it is TutorialItem.DisplayName }.isCompleted) - } - - @Test - fun `a picture completes the picture step`() { - val items = profileTutorialItems(profile(picture = anyPicture)) - assertTrue(items!!.first { it is TutorialItem.ProfilePicture }.isCompleted) - assertFalse(items.first { it is TutorialItem.MinimumTip }.isCompleted) - } - - @Test - fun `a saved minimum tip completes the minimum tip step`() { - val items = profileTutorialItems(profile(minimumTip = Fiat(1.0))) - assertTrue(items!!.first { it is TutorialItem.MinimumTip }.isCompleted) - assertFalse(items.first { it is TutorialItem.ProfilePicture }.isCompleted) - } - - @Test - fun `a name, a picture and a minimum tip take the checklist away entirely`() { - assertNull(profileTutorialItems(profile(picture = anyPicture, minimumTip = Fiat(1.0)))) - } - - @Test - fun `a nameless profile keeps the checklist even with the other two steps done`() { - val items = profileTutorialItems( - profile(name = "", picture = anyPicture, minimumTip = Fiat(1.0)) - ) - assertEquals(listOf(false, true, true), items?.map { it.isCompleted }) - } -} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt deleted file mode 100644 index 37bd34cac0..0000000000 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardDragTest.kt +++ /dev/null @@ -1,118 +0,0 @@ -package com.flipcash.app.menu.internal - -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -/** - * A drag has to move the card by every pixel the finger covered. - * - * Touch events arrive in batches, one batch per frame, so a slow frame hands the drag several - * deltas in a row before any of them can be applied — which is exactly when the animation is - * already struggling and the card can least afford to lose ground. Each delta is applied in a - * coroutine of its own, and `Animatable` serialises those through a mutex that cancels whatever it - * finds running, so "the batch adds up" is a property worth holding on to rather than assuming: a - * drag that read its base value and wrote it back across a cancellation point would lose the - * cancelled delta, and the card would stick under the finger and then lurch. - * - * The dispatcher here queues rather than running inline, which is what reproduces the batch. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class TipCardDragTest { - - @Test - fun `a delta a frame moves the card by all of them`() = runTest { - val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) - - repeat(5) { - expansion.dragBy(0.1f) - advanceUntilIdle() - } - - assertEquals(0.5f, expansion.progress, Tolerance) - } - - @Test - fun `five deltas in one frame's batch move the card by all of them`() = runTest { - val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) - - repeat(5) { expansion.dragBy(0.1f) } - advanceUntilIdle() - - assertEquals(0.5f, expansion.progress, Tolerance) - } - - @Test - fun `a drag past either end stops there`() = runTest { - val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) - - repeat(20) { expansion.dragBy(0.1f) } - advanceUntilIdle() - assertEquals(1f, expansion.progress, Tolerance) - - repeat(40) { expansion.dragBy(-0.1f) } - advanceUntilIdle() - assertEquals(0f, expansion.progress, Tolerance) - } - - @Test - fun `pushing the card down past full screen gives, a quarter of the way`() = runTest { - val expansion = expandedCard() - - expansion.dragBy(0.4f) - advanceUntilIdle() - - assertEquals(1f, expansion.progress, Tolerance) - assertEquals(0.1f, expansion.overdrag, Tolerance) - } - - @Test - fun `a finger that turns around gives back all it pushed before the card moves`() = runTest { - val expansion = expandedCard() - - expansion.dragBy(0.4f) - expansion.dragBy(-0.3f) - advanceUntilIdle() - - // Still home, a quarter of what is left of the push behind it. Unwinding the overdrag at - // its resisted size instead would leave the card moving home while the finger is still - // below where it pushed from. - assertEquals(1f, expansion.progress, Tolerance) - assertEquals(0.025f, expansion.overdrag, Tolerance) - - expansion.dragBy(-0.2f) - advanceUntilIdle() - - assertEquals(0.9f, expansion.progress, Tolerance) - assertEquals(0f, expansion.overdrag, Tolerance) - } - - @Test - fun `the slack the recogniser swallowed is handed back on the first delta only`() = runTest { - val expansion = expandedCard() - - expansion.startDrag(slack = 0.05f) - expansion.dragBy(-0.1f) - advanceUntilIdle() - assertEquals(0.85f, expansion.progress, Tolerance) - - expansion.dragBy(-0.1f) - advanceUntilIdle() - assertEquals(0.75f, expansion.progress, Tolerance) - } - - /** A card dragged all the way out, which is the only state the swipe exists in. */ - private suspend fun TestScope.expandedCard(): TipCardExpansion { - val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler))) - expansion.dragBy(1f) - advanceUntilIdle() - return expansion - } -} - -private const val Tolerance = 1e-4f diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt deleted file mode 100644 index 912d16df15..0000000000 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardOvershootTest.kt +++ /dev/null @@ -1,116 +0,0 @@ -package com.flipcash.app.menu.internal - -import androidx.compose.runtime.BroadcastFrameClock -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.StandardTestDispatcher -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.advanceUntilIdle -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertTrue - -/** - * A released swipe has to bounce, and not much. - * - * The spring carries the release velocity into the settle, and a flick can hand it far more speed - * than the spring would ever have picked up on its own — enough that it sails a fifth of the travel - * past the end it was aiming for, which on the resting end lifts the card clear out of its slot and - * up under the status bar. Capping that velocity is what keeps the bounce to a settle; the cap is - * only meaningful if it leaves a bounce there at all, so both bounds are held here. iOS meets - * neither, having no swipe: its spring always starts from a standstill. - * - * Driven a frame at a time off a [BroadcastFrameClock], so these are the values the card is - * actually drawn at, not a reading of where the animation was aimed. - */ -@OptIn(ExperimentalCoroutinesApi::class) -class TipCardOvershootTest { - - @Test - fun `no flick bounces the card far past full screen`() = runTest { - for (from in ReleasePoints) { - val overshoot = flingTravel(from, velocity = HardFlick).endInclusive - 1f - - assertTrue( - overshoot <= MaxBounce, - "released at $from, the card bounced $overshoot past full screen", - ) - } - } - - @Test - fun `no flick lifts the card far out of its slot`() = runTest { - for (from in ReleasePoints) { - val overshoot = -flingTravel(from, velocity = -HardFlick).start - - assertTrue( - overshoot <= MaxBounce, - "released at $from, the card bounced $overshoot above its resting place", - ) - } - } - - @Test - fun `a flick does still bounce`() = runTest { - assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive > 1f + Tolerance) - assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start < -Tolerance) - } - - @Test - fun `a flick still comes to rest where it was aimed`() = runTest { - assertTrue(flingTravel(from = 0.5f, velocity = HardFlick).endInclusive >= 1f - Tolerance) - assertTrue(flingTravel(from = 0.5f, velocity = -HardFlick).start <= Tolerance) - } -} - -/** Every tenth of the way out, since where a flick is released changes how far it carries past. */ -private val ReleasePoints = (1..9).map { it / 10f } - -/** - * The bounce budget, as a fraction of the card's travel — about 10dp of settle on a 1080x2424 - * display, against the 33dp an uncapped flick spent. - */ -private const val MaxBounce = 0.06f - -/** - * Settles the card from [from] at [velocity] and reports the range it was drawn across on the way - * to rest — which is wider than the range between its two ends if the spring overshoots one. - */ -private suspend fun TestScope.flingTravel( - from: Float, - velocity: Float, -): ClosedFloatingPointRange { - val clock = BroadcastFrameClock() - val expansion = TipCardExpansion(CoroutineScope(StandardTestDispatcher(testScheduler) + clock)) - - expansion.dragBy(from) - advanceUntilIdle() - expansion.settle(velocity, FlingThreshold) - advanceUntilIdle() - - var lowest = expansion.progress - var highest = lowest - repeat(FramesToSettle) { frame -> - clock.sendFrame((frame + 1) * NanosPerFrame) - advanceUntilIdle() - lowest = minOf(lowest, expansion.progress) - highest = maxOf(highest, expansion.progress) - } - return lowest..highest -} - -/** - * Android's maximum fling velocity (8000 px/s) over the ~560 px the card travels between its slot - * and the middle of a 1080x2424 display — the fastest a release can ever hand the spring. - */ -private const val HardFlick = 14.2f - -/** [MinFlingVelocity] over that same travel. */ -private const val FlingThreshold = 0.58f - -private const val NanosPerFrame = 16_666_667L - -/** Two seconds of frames — several times over what this spring needs to come to rest. */ -private const val FramesToSettle = 120 - -private const val Tolerance = 1e-3f diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt deleted file mode 100644 index 1a186781c0..0000000000 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardSettleTest.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.flipcash.app.menu.internal - -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -/** - * Where a swipe on the expanded tip card lands when the finger lifts. - * - * The gesture drags the card back up towards its slot on the "You" tab, so the release has to - * decide between putting it away and springing it back out — and it has to agree with what the - * hand just did. A flick counts on its own, however short; anything slower goes on how much of the - * way home the pull covered. iOS asks the same two questions of its own release. - */ -class TipCardSettleTest { - - /** A velocity well inside the threshold: a drag that was drifting, not thrown. */ - private val slow = FlingThreshold / 4 - - @Test - fun `a slow release goes on how much of the way home the pull covered`() { - assertFalse(settle(progress = 0.2f, velocity = slow)) - assertFalse(settle(progress = 0.69f, velocity = -slow)) - assertTrue(settle(progress = 0.71f, velocity = -slow)) - assertTrue(settle(progress = 0.9f, velocity = slow)) - } - - @Test - fun `a pull that covered under a third of the way home springs back`() { - // Barely started, and nothing in the release says otherwise: the card belongs where it was. - assertTrue(settle(progress = 0.71f, velocity = 0f)) - } - - @Test - fun `a pull that covered a third of the way home finishes the job`() { - // Short of half the travel, deliberately. The card is only ever dragged from one end, by - // someone who has already decided to put it away. - assertFalse(settle(progress = 0.7f, velocity = 0f)) - assertFalse(settle(progress = 0.5f, velocity = 0f)) - } - - @Test - fun `an upward flick closes the card however little of it was dragged`() { - // The whole point of a flick: barely moved, but thrown at the slot it came out of. - assertFalse(settle(progress = 0.98f, velocity = -FlingThreshold)) - } - - @Test - fun `a downward flick puts the card back even from almost closed`() { - // Changed their mind mid-swipe — the card belongs back on screen, not shut. - assertTrue(settle(progress = 0.02f, velocity = FlingThreshold)) - } - - @Test - fun `a motionless card is left where the tap put it`() { - assertTrue(settle(progress = 1f, velocity = 0f)) - assertFalse(settle(progress = 0f, velocity = 0f)) - } - - private fun settle(progress: Float, velocity: Float) = - settlesExpanded(progress, velocity, FlingThreshold) - - private companion object { - /** Stand-in for the density-derived threshold the screen computes; the units cancel. */ - const val FlingThreshold = 2f - } -} diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt deleted file mode 100644 index aebd2666f8..0000000000 --- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/TipCardStateTest.kt +++ /dev/null @@ -1,46 +0,0 @@ -package com.flipcash.app.menu.internal - -import com.flipcash.app.core.bill.Scannable -import com.flipcash.services.models.UserProfile -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -/** - * The "You" tab used to model the tip card as a nullable, which conflated "still resolving" with - * "this account has no display name". The header drew nothing for either, so a nameless account got - * a tab with no card, no prompt and no way to claim one. The three states must stay distinct — and - * only a claimed card is shareable, linkable or expandable. - */ -class TipCardStateTest { - - private val aCard = Scannable.TipCard(data = emptyList(), user = UserProfile.Empty) - - @Test - fun `nothing is shareable while the card is still resolving`() { - val state = MenuScreenViewModel.State() - assertEquals(MenuScreenViewModel.TipCardState.Unknown, state.tipCardState) - assertNull(state.tipCard) - assertNull(state.tipLink) - } - - @Test - fun `an unclaimed stand-in is never treated as the viewer's card`() { - val state = MenuScreenViewModel.State( - tipCardState = MenuScreenViewModel.TipCardState.Unclaimed(placeholder = aCard), - ) - // Drawn blurred behind the claim prompt, but it is not a card the viewer owns: no share, - // no download, no link, and the caller's `canExpand` gate stays closed. - assertNull(state.tipCard) - assertNull(state.tipLink) - } - - @Test - fun `a claimed card carries its shareable link`() { - val state = MenuScreenViewModel.State( - tipCardState = MenuScreenViewModel.TipCardState.Claimed(aCard, "https://flipcash.com/x"), - ) - assertEquals(aCard, state.tipCard) - assertEquals("https://flipcash.com/x", state.tipLink) - } -} diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 519277c41c..4ebd4e40b9 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:chat-ui")) implementation(project(":apps:flipcash:shared:amount-entry")) + implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt index 0ca9807bba..644d61461c 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatMuteStatusChip.kt @@ -6,17 +6,9 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.scaleIn -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.NotificationsOff -import androidx.compose.material3.Icon -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,7 +21,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.clearAndSetSemantics import com.flipcash.features.messenger.R import com.flipcash.services.models.chat.ViewerState -import com.getcode.theme.CodeTheme +import com.flipcash.shared.common.ui.profile.ProfileStatusChip /** * What the chat's mute currently is, drawn under the title on both chat settings surfaces — @@ -103,29 +95,9 @@ private fun Chip( label: String, modifier: Modifier = Modifier, ) { - Row( - modifier = modifier - .background( - color = CodeTheme.colors.warning.copy(alpha = 0.1f), - shape = CircleShape, - ) - .padding( - horizontal = CodeTheme.dimens.staticGrid.x2, - vertical = CodeTheme.dimens.staticGrid.x1, - ), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), - ) { - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), - imageVector = Icons.Outlined.NotificationsOff, - contentDescription = null, - tint = CodeTheme.colors.warning, - ) - Text( - text = label, - style = CodeTheme.typography.textSmall, - color = CodeTheme.colors.warning, - ) - } + ProfileStatusChip( + icon = Icons.Outlined.NotificationsOff, + text = label, + modifier = modifier, + ) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 5ad26f11ec..ee39719123 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -1365,7 +1365,7 @@ internal class ChatViewModel @Inject constructor( openingTipRecipientFlow .flatMapLatest { recipient -> if (recipient == null) flowOf(null) - else tipPaymentDelegate.minimumToOpenDmWith(recipient.profile) + else tipPaymentDelegate.startChattingFee(recipient.profile) } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), null) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt index ad857a7b7b..a06c304f51 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatEventStallTest.kt @@ -111,7 +111,7 @@ class ChatEventStallTest { every { exchange.preferredRate } returns Rate.oneToOne every { transactionController.limits } returns MutableStateFlow(null) every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) coEvery { verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) } returns Result.success(verifiedFiat) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index f7c6fb8bf2..5a2dfd4c17 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -119,7 +119,7 @@ class ChatGroupAnalyticsTest { every { exchange.preferredRate } returns Rate.oneToOne every { transactionController.limits } returns MutableStateFlow(null) every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) // The gate's rule currency is looked up by mint; the name it resolves to is not under test. coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 8ef5e29cf6..1928a9f34d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -116,7 +116,7 @@ class ChatGroupCashLinkTest { coEvery { tokenCoordinator.hasGiveableBalance(any()) } returns true coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) coEvery { verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) } returns Result.success(verifiedFiat) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt index acea67ae51..a41a81b7fd 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatMentionPickerTest.kt @@ -78,7 +78,7 @@ class ChatMentionPickerTest { every { transactionController.limits } returns MutableStateFlow(null) every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) every { tokenCoordinator.observeTokenCache() } returns flowOf(emptyMap()) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) coEvery { tokenCoordinator.getTokenMetadata(any()) } returns Result.failure(RuntimeException()) coEvery { rosterSearch.search(any(), any(), any()) } returns listOf(erica, eric) coEvery { chatCoordinator.sendMessage(any(), any(), any()) } returns Result.success(mockk(relaxed = true)) diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index c1c9be864b..5a8e20a6a0 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -73,7 +73,7 @@ class ChatOpenTranscriptTest { every { userManager.accountCluster } returns mockk(relaxed = true) every { exchange.preferredRate } returns Rate.oneToOne every { transactionController.limits } returns MutableStateFlow(null) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) coEvery { chatCoordinator.generateChatId(userId) } returns Result.success(chatId) } diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 663a96282d..db1b8c418b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -111,7 +111,7 @@ class ChatSendFailureAnalyticsTest { every { exchange.preferredRate } returns Rate.oneToOne every { transactionController.limits } returns MutableStateFlow(null) every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) - every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + every { tipPaymentDelegate.startChattingFee(any()) } returns flowOf(null) coEvery { verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) } returns Result.success(verifiedFiat) 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 index da96818782..cef3125653 100644 --- 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 @@ -56,6 +56,12 @@ val LocalTipCardColor = staticCompositionLocalOf { TipCardOpaqueFallback } */ val TipCardOpaqueFallback = Color(0xFF1A1A1C) +/** + * The card's flattened tone on a static backdrop (no camera behind it), as Figma flattens it. + * Pair with [LocalTipCardBaseAlpha] `1f` for an opaque card. + */ +val TipCardFlattened = Color(0xFF101011) + /** * The card's height-to-width proportion, from Figma (269 x 333 dp). */ diff --git a/apps/flipcash/shared/common-ui/build.gradle.kts b/apps/flipcash/shared/common-ui/build.gradle.kts index 87527e41bd..62850f8aa6 100644 --- a/apps/flipcash/shared/common-ui/build.gradle.kts +++ b/apps/flipcash/shared/common-ui/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(project(":libs:datetime")) implementation(project(":apps:flipcash:shared:theme")) + implementation(libs.compose.material) api(libs.compose.material.icons.extended) testImplementation(libs.robolectric) } diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt index 287e342fb7..9af8fde466 100644 --- a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/ContactAvatar.kt @@ -158,18 +158,21 @@ fun ContactAvatar( ) } +/** + * The blob-backed picture surface behind every avatar, and behind the profile cover, which is why + * it takes the [background] shown while there is nothing to draw. + */ @Composable -private fun ProfileAvatar( +internal fun ProfileAvatar( image: MediaItem?, access: BlobAccessContext, modifier: Modifier, blurred: Boolean = false, + background: Brush = Brush.linearGradient(CodeTheme.colors.contactAvatar.colors), fallback: @Composable BoxWithConstraintsScope.() -> Unit, ) { BoxWithConstraints( - modifier = modifier.background( - Brush.linearGradient(CodeTheme.colors.contactAvatar.colors) - ) + modifier = modifier.background(background) ) { // Decoded once for the whole avatar, not just the loading state: it is the placeholder // while the rendition downloads, what stays on screen while a stale URL is re-minted, and diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt new file mode 100644 index 0000000000..07b9ac798d --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileActionButton.kt @@ -0,0 +1,117 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.getcode.theme.CodeTheme +import com.getcode.theme.White10 + +private val ActionHeight = 38.dp +private val ActionIconSize = 22.dp +private const val PressedAlpha = 0.7f + +/** A 38dp capsule with [text], for the actions that sit beside the avatar on a profile. */ +@Composable +fun ProfileActionButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ActionCapsule( + onClick = onClick, + modifier = modifier.heightIn(min = ActionHeight), + contentPadding = CodeTheme.dimens.staticGrid.x4, + ) { + Text( + text = text, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textMain, + ) + } +} + +/** The 38×38 icon form of [ProfileActionButton]. [contentDescription] is what a screen reader says. */ +@Composable +fun ProfileActionButton( + icon: ImageVector, + contentDescription: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + ActionCapsule( + onClick = onClick, + modifier = modifier.size(ActionHeight), + contentPadding = 0.dp, + ) { + Icon( + modifier = Modifier.size(ActionIconSize), + imageVector = icon, + contentDescription = contentDescription, + tint = CodeTheme.colors.textMain, + ) + } +} + +@Composable +private fun ActionCapsule( + onClick: () -> Unit, + modifier: Modifier, + contentPadding: Dp, + content: @Composable () -> Unit, +) { + val interactionSource = remember { MutableInteractionSource() } + val pressed by interactionSource.collectIsPressedAsState() + Box( + modifier = modifier + .alpha(if (pressed) PressedAlpha else 1f) + .clip(CircleShape) + .background(White10) + .clickable( + interactionSource = interactionSource, + indication = null, + role = Role.Button, + onClick = onClick, + ) + .padding(horizontal = contentPadding), + contentAlignment = Alignment.Center, + ) { + content() + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileActionButtons() { + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) { + ProfileActionButton(text = "Edit Profile", onClick = {}) + ProfileActionButton(icon = Icons.Outlined.IosShare, contentDescription = "Share", onClick = {}) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt new file mode 100644 index 0000000000..7362398616 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileCover.kt @@ -0,0 +1,50 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ProfileAvatar +import com.getcode.theme.CodeTheme + +internal val ProfileCoverHeight = 214.dp + +/** + * The full-bleed picture at the top of a profile. It is drawn under the status bar, so the caller + * lays it out edge to edge and this does not pad for insets. + * + * The [image] is scaled to fill and clipped, with its BlurHash as the placeholder while the + * rendition downloads; with no picture the cover is the plain surface colour. Loading is + * [ProfileAvatar]'s — the same rendition, re-mint and cache path every avatar uses — and [access] + * names the surface the picture is read from, which is what authorizes re-minting it. + */ +@Composable +fun ProfileCover( + image: MediaItem?, + access: BlobAccessContext, + modifier: Modifier = Modifier, +) { + ProfileAvatar( + image = image, + access = access, + modifier = modifier + .fillMaxWidth() + .height(ProfileCoverHeight), + background = SolidColor(CodeTheme.colors.surfaceVariant), + fallback = { }, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileCover_NoPicture() { + ProfileCover(image = null, access = BlobAccessContext.Owned) +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt new file mode 100644 index 0000000000..50d46b0750 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileHeader.kt @@ -0,0 +1,155 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.border +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.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.IosShare +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.models.chat.BlobAccessContext +import com.flipcash.services.models.chat.MediaItem +import com.flipcash.shared.common.ui.ContactAvatar +import com.getcode.theme.CodeTheme + +private val AvatarSize = 84.dp +private val AvatarRing = 5.dp +private val AvatarOverlap = 42.dp + +/** + * The top of a profile: [cover], an avatar overlapping it, the actions beside the avatar, and the + * subject's [title], [subtitle] and [body]. A null [title] (an account with no name yet) draws + * nothing in its place. + * + * It renders a subject, not a user — a person today, a group next — so callers map their model to + * these inputs. [avatar] receives the size, clip and ring to draw with, which keeps the avatar the + * caller's (a user's picture, a group's) while the overlap stays the header's. [underSubtitle] + * holds anything that belongs between the handle and the bio, such as a status chip. + * + * [cover] is laid out edge to edge from the top of its parent and is not padded for the status + * bar; the page gutter applies to everything below it. + */ +@Composable +fun ProfileHeader( + cover: MediaItem?, + access: BlobAccessContext, + avatar: @Composable (Modifier) -> Unit, + title: String?, + subtitle: String?, + body: String?, + actions: @Composable RowScope.() -> Unit, + modifier: Modifier = Modifier, + underSubtitle: @Composable () -> Unit = {}, +) { + val inset = CodeTheme.dimens.inset + Box(modifier = modifier.fillMaxWidth()) { + ProfileCover(image = cover, access = access) + Column(modifier = Modifier.padding(top = ProfileCoverHeight - AvatarOverlap)) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = inset), + verticalAlignment = Alignment.Top, + ) { + avatar( + Modifier + .size(AvatarSize) + .clip(CircleShape) + .border(AvatarRing, CodeTheme.colors.background, CircleShape) + ) + Row( + modifier = Modifier + .weight(1f) + .padding(top = AvatarOverlap + CodeTheme.dimens.staticGrid.x4), + horizontalArrangement = Arrangement.spacedBy( + CodeTheme.dimens.staticGrid.x3, + Alignment.End, + ), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) + } + // iOS sets the text 8pt below the action row. + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = inset) + .padding(top = CodeTheme.dimens.staticGrid.x2), + ) { + if (!title.isNullOrBlank()) { + Text( + text = title, + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + ) + } + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + underSubtitle() + if (!body.isNullOrBlank()) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x3), + text = body, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + } + } + } + } +} + +@Composable +private fun PreviewHeader(withBody: Boolean, withChip: Boolean) { + ProfileHeader( + cover = null, + access = BlobAccessContext.Owned, + avatar = { ContactAvatar(image = null, displayName = "Ada Lovelace", access = BlobAccessContext.Owned, modifier = it) }, + title = "Ada Lovelace", + subtitle = "@ada", + body = "Analytical engines and poetry.".takeIf { withBody }, + actions = { + ProfileActionButton(text = "Edit Profile", onClick = {}) + ProfileActionButton(icon = Icons.Outlined.IosShare, contentDescription = "Share", onClick = {}) + }, + underSubtitle = { + if (withChip) { + ProfileStatusChip( + modifier = Modifier.padding(top = CodeTheme.dimens.staticGrid.x2), + icon = Icons.Outlined.NotificationsOff, + text = "Muted", + ) + } + }, + ) +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileHeader_NoCover_WithBio() = PreviewHeader(withBody = true, withChip = false) + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileHeader_NoCover_NoBio() = PreviewHeader(withBody = false, withChip = true) diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt new file mode 100644 index 0000000000..234ad66682 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileShareSheet.kt @@ -0,0 +1,98 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.ui.Alignment +import androidx.compose.ui.Modifier +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.theme.FlipcashThemeWrapper +import com.flipcash.shared.common.ui.R +import com.getcode.theme.CodeTheme +import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.components.ChoiceRow + +/** + * One choice in a [ProfileShareSheet]. The caller decides which rows exist, so a person's profile + * and a group's can offer different ones; [id] is how the caller recognises the row that comes + * back through `onRow`. + */ +data class ProfileShareRow( + val id: String, + @DrawableRes val icon: Int, + val label: String, +) + +/** + * The content of a profile's share sheet: [title] over [subtitle] (who is being shared), then one + * row per entry in [rows]. + * + * It only draws and reports the tap. Hosting it in a sheet, dismissing that sheet, and running the + * chosen action once the sheet has finished leaving are the caller's. + */ +@Composable +fun ProfileShareSheet( + title: String, + subtitle: String, + rows: List, + onRow: (ProfileShareRow) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .navigationBarsPadding(), + ) { + AppBarWithTitle( + title = title, + titleAlignment = Alignment.CenterHorizontally, + ) + Text( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(vertical = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + rows.forEach { row -> + ChoiceRow( + label = row.label, + icon = row.icon, + onClick = { onRow(row) }, + ) + } + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileShareSheet() { + ProfileShareSheet( + title = "Share User Profile", + subtitle = "Ada Lovelace · @ada", + rows = listOf( + ProfileShareRow("share", R.drawable.ic_share_os, "Share Profile"), + ProfileShareRow("card", R.drawable.ic_file_download, "Show Profile Card"), + ProfileShareRow("copy", R.drawable.ic_copy, "Copy Link"), + ), + onRow = {}, + ) +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt new file mode 100644 index 0000000000..1b3accfba7 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatsCard.kt @@ -0,0 +1,102 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.shared.common.ui.R +import com.getcode.theme.CodeTheme +import com.getcode.theme.White05 + +private val StatsCardHeight = 84.dp +private val LeadingStatWidth = 176.dp +private val StatsDividerHeight = 48.dp + +/** + * The two facts under a profile header: what it costs to start a chat, and when the subject + * joined. Both arrive formatted — the caller owns currency and date formatting — and a null + * renders as a dash rather than collapsing the stat. + */ +@Composable +fun ProfileStatsCard( + minimumToChat: String?, + joined: String?, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .height(StatsCardHeight) + .clip(CodeTheme.shapes.medium) + .background(White05), + verticalAlignment = Alignment.CenterVertically, + ) { + Stat( + modifier = Modifier.width(LeadingStatWidth).fillMaxHeight(), + caption = stringResource(R.string.title_profileMinimumToChat), + value = minimumToChat, + ) + Box( + modifier = Modifier + .width(CodeTheme.dimens.border) + .height(StatsDividerHeight) + .background(CodeTheme.colors.divider) + ) + Stat( + modifier = Modifier.weight(1f).fillMaxHeight(), + caption = stringResource(R.string.title_profileDateJoined), + value = joined, + ) + } +} + +@Composable +private fun Stat( + caption: String, + value: String?, + modifier: Modifier = Modifier, +) { + // Top-aligned in the full card height, so both captions sit at the same inset from the top + // edge rather than floating to the middle. + Column( + modifier = modifier.padding(start = CodeTheme.dimens.staticGrid.x3, top = CodeTheme.dimens.staticGrid.x3), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + ) { + Text( + text = caption, + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + Text( + text = value ?: stringResource(R.string.text_profileStatEmpty), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileStatsCard() { + Column(verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x3)) { + ProfileStatsCard(minimumToChat = "$1.00", joined = "October 2026") + ProfileStatsCard(minimumToChat = null, joined = null) + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt new file mode 100644 index 0000000000..0924d83365 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/kotlin/com/flipcash/shared/common/ui/profile/ProfileStatusChip.kt @@ -0,0 +1,69 @@ +package com.flipcash.shared.common.ui.profile + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Block +import androidx.compose.material.icons.outlined.NotificationsOff +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.getcode.theme.CodeTheme + +/** + * A warning-tinted pill saying one thing about the viewer's relationship to the subject — Muted, + * Blocked. Amber at a tenth strength rather than a theme token, because what it has to sit on is + * whatever the host screen's background is; tinted rather than solid so it stays under the title. + */ +@Composable +fun ProfileStatusChip( + icon: ImageVector, + text: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .background( + color = CodeTheme.colors.warning.copy(alpha = 0.1f), + shape = CircleShape, + ) + .padding( + horizontal = CodeTheme.dimens.staticGrid.x2, + vertical = CodeTheme.dimens.staticGrid.x1, + ), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x1), + ) { + Icon( + modifier = Modifier.size(CodeTheme.dimens.staticGrid.x3), + imageVector = icon, + contentDescription = null, + tint = CodeTheme.colors.warning, + ) + Text( + text = text, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.warning, + ) + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_ProfileStatusChip() { + Row(horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.staticGrid.x2)) { + ProfileStatusChip(icon = Icons.Outlined.NotificationsOff, text = "Muted") + ProfileStatusChip(icon = Icons.Outlined.Block, text = "Blocked") + } +} diff --git a/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml new file mode 100644 index 0000000000..51a0a846a3 --- /dev/null +++ b/apps/flipcash/shared/common-ui/src/main/res/values/strings.xml @@ -0,0 +1,6 @@ + + + Minimum to Chat + Date Joined + — + diff --git a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt index 3e2e87f94d..902dac083f 100644 --- a/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt +++ b/apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/shared/payments/TipPaymentDelegate.kt @@ -139,19 +139,19 @@ class TipPaymentDelegate @Inject constructor( * to the currency the sender is entering in. A missing rate for either side falls back to the * preset rather than stating a floor in a currency the entry isn't using. */ - fun minimumToOpenDmWith(recipient: UserProfile?): Flow = + fun startChattingFee(recipient: UserProfile?): Flow = combine(minTipAmount, exchange.observePreferredRate()) { preset, rate -> recipient?.minDmChatInitFee?.inCurrency(rate.currency) ?: preset } /** * The floor for a tip to [userId]. The fee buys the conversation, so only the tip that opens it - * pays [minimumToOpenDmWith]; once a DM with them exists, every tip after it sits on the system + * pays [startChattingFee]; once a DM with them exists, every tip after it sits on the system * [minTipAmount] like any other. */ fun minimumTipFor(userId: ID, recipient: UserProfile?): Flow = flow { val opensTheChat = chatCoordinator.getChatId(userId).isFailure - emitAll(if (opensTheChat) minimumToOpenDmWith(recipient) else minTipAmount) + emitAll(if (opensTheChat) startChattingFee(recipient) else minTipAmount) } /** diff --git a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt index 1d46276720..86d165c8e3 100644 --- a/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt +++ b/apps/flipcash/shared/payments/src/test/kotlin/com/flipcash/shared/payments/TipPaymentDelegateTest.kt @@ -155,19 +155,19 @@ class TipPaymentDelegateTest { } @Test - fun `minimumToOpenDmWith is the recipient's own fee, not the regional preset`() = runTest { + fun `startChattingFee is the recipient's own fee, not the regional preset`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) val recipient = recipient(Fiat(5.0, CurrencyCode.USD)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } assertEquals(5.0, min!!.toDouble()) assertEquals(CurrencyCode.USD, min.currencyCode) } @Test - fun `minimumToOpenDmWith converts the recipient's fee into the currency being entered`() = runTest { + fun `startChattingFee converts the recipient's fee into the currency being entered`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 2.0, currency = CurrencyCode.EUR)) // The fee was set in CAD at half a dollar to the dollar; the sender enters in EUR at two. @@ -175,7 +175,7 @@ class TipPaymentDelegateTest { every { exchange.rateFor(CurrencyCode.EUR) } returns Rate(fx = 2.0, currency = CurrencyCode.EUR) val recipient = recipient(Fiat(10.0, CurrencyCode.CAD)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } // CAD 10 → USD 5 → EUR 10. assertEquals(10.0, min!!.toDouble()) @@ -183,25 +183,25 @@ class TipPaymentDelegateTest { } @Test - fun `minimumToOpenDmWith falls back to the preset when the recipient charges nothing`() = runTest { + fun `startChattingFee falls back to the preset when the recipient charges nothing`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) val delegate = buildDelegate() - assertEquals(1.0, delegate.minimumToOpenDmWith(recipient(fee = null)).first { it != null }!!.toDouble()) - assertEquals(1.0, delegate.minimumToOpenDmWith(null).first { it != null }!!.toDouble()) + assertEquals(1.0, delegate.startChattingFee(recipient(fee = null)).first { it != null }!!.toDouble()) + assertEquals(1.0, delegate.startChattingFee(null).first { it != null }!!.toDouble()) } @Test - fun `minimumToOpenDmWith falls back to the preset rather than state a floor in another currency`() = runTest { + fun `startChattingFee falls back to the preset rather than state a floor in another currency`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) // No rate for the currency the recipient set their fee in. every { exchange.rateToUsd(CurrencyCode.CAD) } returns null val recipient = recipient(Fiat(10.0, CurrencyCode.CAD)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } assertEquals(1.0, min!!.toDouble()) assertEquals(CurrencyCode.USD, min.currencyCode) @@ -223,7 +223,7 @@ class TipPaymentDelegateTest { } @Test - fun `minimumToOpenDmWith rounds the converted fee up so it can't be undercut`() = runTest { + fun `startChattingFee rounds the converted fee up so it can't be undercut`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) // 600 INR at 95.95 INR/USD is 6.2533... USD — half-up rounding states it as $6.25, which is @@ -232,38 +232,65 @@ class TipPaymentDelegateTest { every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 95.95, currency = CurrencyCode.USD) val recipient = recipient(Fiat(600.0, CurrencyCode.INR)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } assertEquals(6.26, min!!.toDouble()) assertTrue(min.toDouble() * 95.95 >= 600.0) } @Test - fun `minimumToOpenDmWith rounds another currency's converted fee up too`() = runTest { + fun `startChattingFee rounds another currency's converted fee up too`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) // 1500 NGN at 1326 NGN/USD is 1.1312... USD, which rounds up to $1.14 (not the half-up $1.13). every { exchange.rateToUsd(CurrencyCode.NGN) } returns Rate(fx = 1 / 1326.0, currency = CurrencyCode.USD) val recipient = recipient(Fiat(1500.0, CurrencyCode.NGN)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } assertEquals(1.14, min!!.toDouble()) } @Test - fun `minimumToOpenDmWith does not bump a fee that converts exactly`() = runTest { + fun `startChattingFee does not bump a fee that converts exactly`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) // 600 INR at an even 100 INR/USD is exactly $6.00 — rounding up must not bump an exact value. every { exchange.rateToUsd(CurrencyCode.INR) } returns Rate(fx = 1 / 100.0, currency = CurrencyCode.USD) val recipient = recipient(Fiat(600.0, CurrencyCode.INR)) - val min = buildDelegate().minimumToOpenDmWith(recipient).first { it != null } + val min = buildDelegate().startChattingFee(recipient).first { it != null } assertEquals(6.00, min!!.toDouble()) } + @Test + fun `startChattingFee falls back to the regional minimum when the recipient set none`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 1.0, currency = CurrencyCode.USD)) + + val fee = buildDelegate().startChattingFee(recipient(fee = null)).first { it != null } + + assertEquals(1.0, fee!!.toDouble()) + assertEquals(CurrencyCode.USD, fee.currencyCode) + } + + @Test + fun `startChattingFee uses the recipient's fee converted to the preferred currency`() = runTest { + every { userFlags.resolvedFlags } returns usdPresets() + every { exchange.observePreferredRate() } returns flowOf(Rate(fx = 2.0, currency = CurrencyCode.EUR)) + every { exchange.rateToUsd(CurrencyCode.CAD) } returns Rate(fx = 0.5, currency = CurrencyCode.USD) + every { exchange.rateFor(CurrencyCode.EUR) } returns Rate(fx = 2.0, currency = CurrencyCode.EUR) + + val fee = buildDelegate() + .startChattingFee(recipient(Fiat(10.0, CurrencyCode.CAD))) + .first { it != null } + + // CAD 10 -> USD 5 -> EUR 10. + assertEquals(10.0, fee!!.toDouble()) + assertEquals(CurrencyCode.EUR, fee.currencyCode) + } + @Test fun `minimumTipFor drops to the system minimum once a chat exists`() = runTest { every { userFlags.resolvedFlags } returns usdPresets() diff --git a/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml b/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml deleted file mode 100644 index 53eaf597c2..0000000000 --- a/ui/resources/src/main/res/drawable/ic_chevron_down_medium.xml +++ /dev/null @@ -1,9 +0,0 @@ - - -