diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt index 2ed3e1d666..3c8f6c8ad5 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/chat/ChatStep.kt @@ -3,6 +3,7 @@ package com.flipcash.app.core.chat import android.os.Parcelable import com.flipcash.services.models.chat.ChatId import com.getcode.solana.keys.Mint +import com.getcode.navigation.FullscreenSheet import com.getcode.navigation.HalfSheet import com.getcode.navigation.Sheet import com.getcode.navigation.WrapContentSheet @@ -189,6 +190,28 @@ sealed interface ChatStep : FlowStep, Parcelable { * sheet (node 10330:18091), opened from the pill row's "$". The sheet picks which reaction from * the viewer's own. Short and fixed, so it wraps its content. */ + /** + * Frames 03–06 — a private group's Request Money history, and the create flow behind its New + * Request button, on a back stack of its own. Presented as [ShareMessage] is, a full sheet + * with a close at its root; its steps are non-draggable, so the close is the only way out. + */ + @Parcelize + @Serializable + data class RequestMoney(val chatId: ChatId) : ChatStep, Sheet, FullscreenSheet + + /** + * Frames 09, 11, 24 and 35 — one payment request in [chatId]. [requestId] is the request's id + * in base58, so the step stays a plain parcelable. A full sheet like [RequestMoney], closed + * only from its app bar; `ChatFlowScreen` turns the drag-to-dismiss off. [pay] opens it + * straight into paying the viewer's share, as the deck's Pay button does. + */ + @Parcelize + @Serializable + data class RequestDetails(val chatId: ChatId, val requestId: String, val pay: Boolean = false) : + ChatStep, + Sheet, + FullscreenSheet + @Parcelize @Serializable data class ReactionBoost( diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 624ead7119..1eac9b3f3e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -1014,6 +1014,159 @@ %1$s sent %2$s %1$s tipped %2$s Shared a profile + Request + You sent request for %1$s + %1$s sent request for %2$s + You sent request + %1$s sent request + Send Money + Send money to someone in the group, or multiple people + Request Money + Collect money from people in the group + + Request Money + New Request + Open Requests + Previous Requests + %1$s paid, %2$s requested + ✓ %1$s paid + Amount Per Person + New Request + per person + Name + What is the request for? + People + Timing + + %1$d day left to pay + %1$d days left to pay + + + %1$d hour left to pay + %1$d hours left to pay + + + %1$d minute left to pay + %1$d minutes left to pay + + Paid in full + Closed + Expired + + + %1$d day left + %1$d days left + + + %1$d hour left + %1$d hours left + + + %1$d minute left + %1$d minutes left + + + You owe %1$s + ✓ You paid %1$s + Your request + you paid %1$s + you owe %1$s + your request + Pay + Pay %1$s + You owe %1$s for %2$s, %3$s + + %1$s of %2$s + + %1$s, %2$s of %3$s paid, %4$s + + %1$s, %2$s, %3$s of %4$s paid, %5$s + + %1$d request + %1$d requests + + Next request + + %1$s Request + Everyone, Including You + Everyone Except You + Choose People + + %1$d Person, Including You + %1$d People, Including You + + + %1$d Person + %1$d People + + You\'ll be marked as paid + + %1$d Day To Pay + %1$d Days To Pay + + + Until %1$s + Request automatically closes after + + Request %2$s from %1$d Person + Request %2$s from %1$d People + + You + 100 People Max + A request can include up to 100 people. + Time To Pay + 1 Day + 3 Days + 1 Week + 2 Weeks + 4 Weeks + Custom + + %2$s · closes %1$d day after you send + %2$s · closes %1$d days after you send + + + Custom · %1$d day + Custom · %1$d days + + Custom · today + Starts + Today, on send + Closes + ≈ %1$s + Time + Select time + Type a time + Pick a time on the dial + OK + Send Request? + You will receive and hold the money collected + Send Request + Can\'t Send Request + You can\'t request money in this group. + Choose a deadline within the next 4 weeks. + Someone you included is no longer in the group. Check who\'s included and try again. + Requests aren\'t available in this currency yet. + + Request + Requested by %1$s + Requested by you + ✓ Paid + Hasn\'t paid yet + Didn\'t pay + + %1$s · Hasn\'t paid yet + Send %1$s + Request options + Close + Close Request + Close Request? + This will stop payments. Anyone who hasn\'t paid will be marked Didn\'t pay. Payments already made won\'t be affected. + Mark as Paid + Mark %1$s as Paid? + Use this if %1$s paid you outside Flipcash. No money will be sent. This action cannot be reversed. + Request Closed + This request is no longer accepting payments. Nothing yet Is Typing… diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt index 91c2db89a2..32ce5b1874 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/ChatFlowScreen.kt @@ -5,12 +5,14 @@ import androidx.compose.runtime.Composable import androidx.compose.material3.Text import androidx.activity.compose.BackHandler import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource @@ -42,6 +44,9 @@ import com.flipcash.app.messenger.internal.screens.ReactionPickerSheet import com.flipcash.app.messenger.internal.screens.ReactorsSheet import com.flipcash.app.messenger.internal.screens.ReactorsViewModel import com.flipcash.app.messenger.internal.screens.ShareToChatsSubject +import com.flipcash.app.messenger.internal.request.ChatRequestsViewModel +import com.flipcash.app.messenger.internal.request.RequestDetailsScreen +import com.flipcash.app.messenger.internal.request.RequestMoneyScreen import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostAmountViewModel import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostCurrencySheet import com.flipcash.app.messenger.internal.screens.boost.ReactionBoostSheet @@ -82,11 +87,13 @@ import com.getcode.navigation.scenes.LocalSheetNavigator import com.getcode.navigation.scenes.ModalBottomSheetSceneStrategy import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle +import com.getcode.ui.utils.LocalSheetOverhang import com.getcode.ui.utils.rememberKeyboardController import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import com.getcode.utils.base58 @Composable fun ChatFlowScreen( @@ -198,6 +205,13 @@ private fun chatEntryProvider( val viewModel = flowSharedViewModel() val dismissSheet = LocalBottomSheetDismissDispatcher.current val navigator = LocalCodeNavigator.current + // Closed by its X only, as Request details is: no flow host to carry NonDraggableRoute. + val sheetNavigator = LocalSheetNavigator.current + DisposableEffect(sheetNavigator) { + val previous = sheetNavigator?.sheetDragDisabled ?: false + sheetNavigator?.sheetDragDisabled = true + onDispose { sheetNavigator?.sheetDragDisabled = previous } + } GroupSendDetailsSheet( tokenName = step.tokenName, tokenImageUrl = step.tokenImageUrl, @@ -220,6 +234,48 @@ private fun chatEntryProvider( ) } + annotatedEntry { step -> + val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current + RequestMoneyScreen( + chatId = step.chatId, + resultStateRegistry = resultStateRegistry, + onClose = { dismissSheet() }, + onOpenScreen = { route -> navigator.navigate(route) }, + ) + } + + annotatedEntry { step -> + val navigator = LocalCodeNavigator.current + val dismissSheet = LocalBottomSheetDismissDispatcher.current + // No inner flow host to carry a NonDraggableRoute up to the sheet, so turn the drag off + // here, as FlowHost would: the app bar's close is the only way out. The flag is the chat + // flow's, shared with a Request Money sheet underneath, so put back what was there. + val sheetNavigator = LocalSheetNavigator.current + DisposableEffect(sheetNavigator) { + val previous = sheetNavigator?.sheetDragDisabled ?: false + sheetNavigator?.sheetDragDisabled = true + onDispose { sheetNavigator?.sheetDragDisabled = previous } + } + // Opened from the expanded deck, which stays as it was while the sheet rises over it. Once + // the sheet is fully up, snap the deck minimized where nobody sees it, so coming back shows + // the chat with the deck in the app bar and no collapse playing as the sheet leaves. + val requests = flowSharedViewModel() + val overhang = LocalSheetOverhang.current + LaunchedEffect(requests) { + snapshotFlow { overhang() }.first { it > 1f } + snapshotFlow { overhang() }.first { it <= 1f } + requests.setExpanded(false) + } + RequestDetailsScreen( + chatId = step.chatId, + requestId = step.requestId, + payOnOpen = step.pay, + onBack = { dismissSheet() }, + onOpenScreen = { route -> navigator.navigate(route) }, + ) + } + annotatedEntry { step -> FlowReactionPickerScreen(step.messageId) } @@ -278,6 +334,15 @@ private fun FlowConversationScreen( } } + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .collect { + val chatId = viewModel.stateFlow.value.chatId ?: return@collect + keyboard.hideIfVisible { navigator.push(ChatStep.RequestMoney(chatId)) } + } + } + LaunchedEffect(viewModel) { viewModel.eventFlow .filterIsInstance() 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 6076b7fd2b..39ca1a62a6 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 @@ -24,6 +24,7 @@ import androidx.paging.cachedIn import androidx.paging.flatMap import androidx.paging.map import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.funding.bottomBarCardAction import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.GroupAccess as AnalyticsGroupAccess import com.flipcash.analytics.GroupGateFunding @@ -1001,6 +1002,8 @@ internal class ChatViewModel @AssistedInject constructor( /** @param toMembers whether this is a group send, which opens [ChatStep.GroupSendAmountEntry]. */ data class NavigateToAmountEntry(val toMembers: Boolean) : Event + /** Request Money history, for a private group. */ + data object NavigateToRequestMoney : Event data object PresentDepositOptions : Event data class OpenScreen(val route: AppRoute, val asSheet: Boolean = false): Event @@ -1801,6 +1804,8 @@ internal class ChatViewModel @AssistedInject constructor( when (body.widget) { is WidgetContent.ShareProfile -> resources.getString(R.string.label_chat_preview_sharedProfile) + is WidgetContent.PaymentRequest -> + resources.getString(R.string.label_paymentRequest_card) WidgetContent.Unsupported -> "" } ) @@ -3601,22 +3606,9 @@ internal class ChatViewModel @AssistedInject constructor( // Event.OnSendRequested). .filter { stateFlow.value.participant != null || stateFlow.value.chatType == ChatType.GROUP } .onEach { - if (!startChattingPayer.mayProceed( - onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, - onDiscoverCurrencies = { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) }, - ) - ) { - return@onEach - } - val toMembers = stateFlow.value.sendsToMembers - amountStyleFlow.value = amountStyle(toMembers) - dispatchEvent( - Event.GroupSendUpdated( - GroupSendState(viewerId = userManager.accountId).takeIf { toMembers } - ) - ) - amountDelegate.reset() - dispatchEvent(Event.NavigateToAmountEntry(toMembers)) + // A private group can also ask its members for money, so the button offers both. + // Only sending spends anything, so only sending runs the balance gate. + if (stateFlow.value.sendsToMembers) presentMoneyActions() else startSend() }.launchIn(viewModelScope) observeGroupSend() @@ -3814,6 +3806,49 @@ internal class ChatViewModel @AssistedInject constructor( } } + /** Node 12243:1009 — Send Money or Request Money, as cards like the Add Money sheet's. */ + private fun presentMoneyActions() { + // Two rows and nothing else, as the spec lays the sheet out: no title, no Dismiss. + BottomBarManager.showMessage( + title = "", + actions = listOf( + bottomBarCardAction( + title = resources.getString(R.string.action_sendMoney), + subtitle = resources.getString(R.string.subtitle_sendMoney_group), + iconRes = R.drawable.ic_menu_withdraw, + testTag = "money_action_send", + onClick = { viewModelScope.launch { startSend() } }, + ), + bottomBarCardAction( + title = resources.getString(R.string.action_requestMoney), + subtitle = resources.getString(R.string.subtitle_requestMoney_group), + iconRes = R.drawable.ic_menu_deposit, + testTag = "money_action_request", + onClick = { dispatchEvent(Event.NavigateToRequestMoney) }, + ), + ), + ) + } + + private suspend fun startSend() { + if (!startChattingPayer.mayProceed( + onAddMoney = { dispatchEvent(Event.PresentDepositOptions) }, + onDiscoverCurrencies = { dispatchEvent(Event.OpenScreen(AppRoute.Token.Discovery, asSheet = true)) }, + ) + ) { + return + } + val toMembers = stateFlow.value.sendsToMembers + amountStyleFlow.value = amountStyle(toMembers) + dispatchEvent( + Event.GroupSendUpdated( + GroupSendState(viewerId = userManager.accountId).takeIf { toMembers } + ) + ) + amountDelegate.reset() + dispatchEvent(Event.NavigateToAmountEntry(toMembers)) + } + /** Select People and the confirm sheet of a private group's send. See [GroupSendState]. */ private fun observeGroupSend() { fun update(transform: (GroupSendState) -> GroupSendState?) { @@ -4222,6 +4257,7 @@ internal class ChatViewModel @AssistedInject constructor( state.copy(stagedPhotos = (event.photos + state.stagedPhotos).take(MAX_STAGED_PHOTOS)) } is Event.NavigateToAmountEntry -> { state -> state.copy(sendProgress = LoadingSuccessState()) } + Event.NavigateToRequestMoney -> { state -> state } is Event.PresentDepositOptions -> { state -> state } is Event.OpenScreen -> { state -> state } is Event.MentionTapped -> { state -> state } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt new file mode 100644 index 0000000000..f9f9a04329 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequests.kt @@ -0,0 +1,50 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.repeatOnLifecycle +import com.flipcash.services.models.chat.ChatId +import com.flipcash.shared.chat.ui.request.PaymentRequestCards +import com.getcode.opencode.model.core.ID + +/** + * The transcript's payment request cards for a group chat, and the poll that keeps them current + * while the chat is on screen with the app in the foreground. Null [chatId] (a DM, or a chat still + * resolving) polls nothing and draws request widgets as unsupported. + */ +@Composable +internal fun rememberPaymentRequestCards( + viewModel: ChatRequestsViewModel, + chatId: ChatId?, + onOpen: (ChatId, ID) -> Unit, +): PaymentRequestCards? { + val lifecycleOwner = LocalLifecycleOwner.current + LaunchedEffect(chatId, lifecycleOwner) { + chatId ?: return@LaunchedEffect + lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { viewModel.poll(chatId) } + } + chatId ?: return null + val held by viewModel.held.collectAsStateWithLifecycle() + val missing by viewModel.missing.collectAsStateWithLifecycle() + val now by viewModel.now.collectAsStateWithLifecycle() + val open by rememberUpdatedState(onOpen) + return remember(chatId, held, missing, now) { + PaymentRequestCards( + chatId = chatId, + viewerId = viewModel.viewerId, + requests = held, + missing = missing, + now = now, + openedAt = viewModel.openedAt, + onNeeded = viewModel::fetchIfAbsent, + onRefresh = viewModel::refresh, + onOpen = { open(chatId, it) }, + ) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt new file mode 100644 index 0000000000..e9cab01c02 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/ChatRequestsViewModel.kt @@ -0,0 +1,134 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.deckOrder +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.PaymentRequests +import com.getcode.opencode.model.core.ID +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import kotlin.time.Clock +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.seconds +import kotlin.time.Instant + +/** + * A group chat's payment requests as the conversation screen shows them: the cards in the + * transcript and the deck in the app bar. Owns the screen's one-minute clock and its 45 s poll. + */ +@HiltViewModel +internal class ChatRequestsViewModel @Inject constructor( + private val requests: PaymentRequests, + private val userManager: UserManager, +) : ViewModel() { + + val viewerId: ID? get() = userManager.accountId + + private val chatId = MutableStateFlow(null) + + private val _now = MutableStateFlow(Clock.System.now()) + + /** The time for status lines and the ended state, ticking once a minute. */ + val now: StateFlow = _now.asStateFlow() + + /** When this screen opened, so a card can tell a message that arrived from one scrolled to. */ + val openedAt: Instant = Clock.System.now() + + /** How many times the deck was swiped since the last refetch. View state only. */ + private val cycled = MutableStateFlow(0) + + private val _expanded = MutableStateFlow(false) + val expanded: StateFlow = _expanded.asStateFlow() + + val held: StateFlow> = requests.state + val missing: StateFlow> = requests.missing + + private val _deck = MutableStateFlow>(emptyList()) + + /** The deck, front card first. Empty hides it. */ + val deck: StateFlow> = _deck.asStateFlow() + + /** Requests refetched because a message about them arrived, so a re-shown row doesn't refetch. */ + private val refreshed = mutableSetOf() + + init { + viewModelScope.launch { + combine( + chatId.flatMapLatest { id -> id?.let { requests.inChat(it) } ?: flowOf(emptyList()) }, + _now, + cycled, + ) { inChat, now, cycled -> deckOrder(inChat, now, viewerId).rotated(cycled) } + .collect { _deck.value = it } + } + viewModelScope.launch { + while (true) { + delay(1.minutes) + _now.value = Clock.System.now() + } + } + } + + /** + * Points the deck at [chat] and fills it from the requests already held, so a chat opened with + * known requests shows its deck on the first frame instead of fading it in. + */ + fun attach(chat: ChatId) { + if (chatId.value == chat) return + chatId.value = chat + val held = requests.state.value.values.filter { it.chatId == chat } + _deck.value = deckOrder(held, _now.value, viewerId).rotated(cycled.value) + } + + /** + * Loads [chat]'s requests now and every 45 s until cancelled. Run it while the chat is on + * screen with the app in the foreground; each pass puts the deck back in its default order. + */ + suspend fun poll(chat: ChatId) { + chatId.value = chat + while (true) { + requests.loadChat(chat).onSuccess { cycled.value = 0 } + _now.value = Clock.System.now() + delay(POLL_INTERVAL) + } + } + + fun fetchIfAbsent(id: ID) { + viewModelScope.launch { requests.fetchIfAbsent(id) } + } + + fun refresh(id: ID) { + if (!refreshed.add(id)) return + viewModelScope.launch { requests.fetch(id) } + } + + /** Swipe down on the deck: the front card goes to the back. */ + fun cycle() { + if (deck.value.size > 1) cycled.update { it + 1 } + } + + fun setExpanded(expanded: Boolean) { + _expanded.value = expanded && deck.value.isNotEmpty() + } + + companion object { + val POLL_INTERVAL = 45.seconds + } +} + +internal fun List.rotated(by: Int): List { + if (size < 2) return this + val shift = by.mod(size) + return drop(shift) + take(shift) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt new file mode 100644 index 0000000000..0c8e3f3462 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/DeckHostState.kt @@ -0,0 +1,145 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.spring +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.unit.Velocity +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.flow.collectLatest + +/** + * Where the deck rests when it isn't expanded, and the owed requests it has already shown the strip + * for. Once the strip has collapsed into the pill it stays there for the visit, until a request + * the viewer owes on arrives that the strip hasn't shown. + */ +internal data class DeckRest( + val host: DeckPlacement = DeckPlacement.Pill, + val seenOwed: Set = emptySet(), +) { + /** The requests the viewer has an unpaid share in, as of now. */ + fun owing(owed: Set): DeckRest = when { + owed.isEmpty() -> DeckRest(DeckPlacement.Pill, seenOwed) + (owed - seenOwed).isNotEmpty() -> DeckRest(DeckPlacement.Strip, seenOwed + owed) + else -> copy(seenOwed = seenOwed + owed) + } + + /** The strip has shrunk all the way into the pill. */ + fun collapsed(): DeckRest = copy(host = DeckPlacement.Pill) +} + +/** + * The one transition the deck's three hosts (the pill, the strip and the expanded list) show and + * hide on, so the front card moves between any two of them as one shared element. Scrolling the + * timeline away from the newest messages seeks the strip into the pill, following the scroll. + */ +@Stable +internal class DeckHostState(owed: Set) { + var rest by mutableStateOf(DeckRest().owing(owed)) + private set + + // Requests already held when the chat opens put it in place on the first frame. + val transition = SeekableTransitionState(rest.host) + + var expanded by mutableStateOf(false) + + /** The strip's full height with its padding, which a scroll of that length collapses. */ + var stripPx by mutableFloatStateOf(0f) + + private var collapsePx by mutableFloatStateOf(0f) + private var snapNext = false + private var hadDeck = owed.isNotEmpty() + + /** Partway between the strip and the pill, where a tap shouldn't expand it. */ + val collapsing: Boolean get() = collapsePx > 0f + + fun owing(owed: Set, hasDeck: Boolean) { + val next = rest.owing(owed) + // A deck held when the chat opens is in place on the first frame rather than moving there. + if (!hadDeck && hasDeck && next.host == DeckPlacement.Strip) snapNext = true + if (hasDeck) hadDeck = true + if (next.host == DeckPlacement.Pill) collapsePx = 0f + rest = next + } + + private val target: DeckPlacement + get() = if (expanded) DeckPlacement.Expanded else rest.host + + private val following: Boolean + get() = !expanded && rest.host == DeckPlacement.Strip && + transition.currentState == DeckPlacement.Strip && stripPx > 0f + + /** + * Collapses the strip by however far the timeline moved toward older messages, and opens it + * back by however far it moved toward newer ones. The timeline scrolls as it would anyway. + * This list is reverseLayout, so toward older is a positive y. + */ + val scrollConnection = object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (!following) return Offset.Zero + // At the newest messages a drag back toward them moves nothing, so count what's left. + val moved = consumed.y + minOf(available.y, 0f) + collapsePx = (collapsePx + moved).coerceIn(0f, stripPx) + if (collapsePx >= stripPx) pin() + return Offset.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + settle() + return Velocity.Zero + } + } + + /** Finishes a collapse released partway, toward whichever end is nearer. */ + private suspend fun settle() { + val from = collapsePx + if (from <= 0f || stripPx <= 0f) return + val to = if (from >= stripPx / 2) stripPx else 0f + animate(from, to, animationSpec = SETTLE_SPRING) { value, _ -> collapsePx = value } + if (to > 0f) pin() + } + + private fun pin() { + rest = rest.collapsed() + collapsePx = 0f + } + + /** Puts the transition where the state says, for as long as the screen is up. */ + suspend fun run() { + snapshotFlow { Triple(target, collapsePx, stripPx) }.collectLatest { (target, px, full) -> + when { + snapNext -> { + snapNext = false + transition.snapTo(target) + } + target == DeckPlacement.Strip && px > 0f && full > 0f && + transition.currentState == DeckPlacement.Strip -> + transition.seekTo((px / full).coerceIn(0f, 1f), DeckPlacement.Pill) + transition.currentState != target || transition.targetState != target -> + transition.animateTo(target) + } + } + } +} + +private val SETTLE_SPRING = spring(stiffness = Spring.StiffnessMediumLow) + +/** A [DeckHostState] for one visit to a chat; reopening the chat starts it over. */ +@Composable +internal fun rememberDeckHostState(chat: Any?, owed: Set): DeckHostState { + val state = remember(chat) { DeckHostState(owed) } + LaunchedEffect(state) { state.run() } + return state +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt new file mode 100644 index 0000000000..25b8c357c9 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/NewRequestDraft.kt @@ -0,0 +1,131 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.IdempotencyKey +import com.flipcash.services.models.paymentrequest.NewSplitRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.Fiat +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.hours +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Instant + +/** + * A payment request a creator is putting together in a private group, from Amount Per Person to + * the confirm sheet. Frames 04–06 of the group requests spec. + * + * @param members who Select People lists: the creator first, then the rest of the roster in its + * order. Shares are sent in this order, whatever order the boxes were checked in. + * @param selected who is asked, the creator included when [viewerId] is in it. + * @param idempotencyKey minted when the confirm sheet first opens, and reused by every retry of + * that send. + * @param createdRequestId set once Create returns OK. From then on only the message send is + * retried; Create is never called again for this confirm. + */ +internal data class NewRequestDraft( + val viewerId: ID, + val members: List, + val title: String, + val perPerson: Fiat? = null, + val selected: Set = if (members.size > MAX_SHARES) emptySet() else members.toSet(), + val deadline: Deadline = Deadline.Week, + val idempotencyKey: IdempotencyKey? = null, + val createdRequestId: ID? = null, +) { + /** Members asked other than the creator: the N in "Request $20 from N People". */ + val requestedCount: Int get() = selected.count { it != viewerId } + + val includesCreator: Boolean get() = viewerId in selected + + /** Select People's button: at least one member other than the creator. */ + val canContinue: Boolean get() = requestedCount > 0 + + val canSend: Boolean get() = canContinue && perPerson != null && title.isNotBlank() + + /** Who is asked, in [members] order. */ + val shares: List get() = members.filter { it in selected } + + val people: People + get() { + val others = members.count { it != viewerId } + return when { + selected.isEmpty() -> People.Choose + includesCreator && requestedCount == others -> People.EveryoneIncludingYou + !includesCreator && requestedCount == others -> People.EveryoneExceptYou + includesCreator -> People.SomeIncludingYou(requestedCount) + else -> People.Some(requestedCount) + } + } + + /** [selected] with [member] checked or unchecked, or null when checking them would make a 101st share. */ + fun toggled(member: ID): Set? = when { + member in selected -> selected.minusElement(member) + selected.size >= MAX_SHARES -> null + else -> selected.plusElement(member) + } + + /** What Create sends, with [now] the moment Send Request was tapped. */ + fun toSplit(chatId: ChatId, text: PaymentRequestText.Encrypted, now: Instant): NewSplitRequest? { + val amount = perPerson ?: return null + if (!canContinue) return null + return NewSplitRequest( + chatId = chatId, + text = text, + currency = amount.currencyCode, + endsAt = deadline.endsAt(now), + shares = shares, + perPerson = amount, + ) + } + + /** The summary's People value. The second line shows whenever [markedPaid]. */ + sealed interface People { + val markedPaid: Boolean get() = false + + data object Choose : People + data object EveryoneIncludingYou : People { override val markedPaid = true } + data object EveryoneExceptYou : People + data class SomeIncludingYou(val others: Int) : People { override val markedPaid = true } + data class Some(val count: Int) : People + } + + /** The Time To Pay choice. A preset resolves against the moment Send is tapped, so it never drifts shorter. */ + sealed interface Deadline { + fun endsAt(now: Instant): Instant + + enum class Preset(val length: Duration, val daysToPay: Int) : Deadline { + Day(1.days, 1), + ThreeDays(3.days, 3), + Week(7.days, 7), + TwoWeeks(14.days, 14), + + // Five minutes short of the server's limit, so a device clock running slightly ahead still passes. + FourWeeks(28.days - 5.minutes, 28); + + override fun endsAt(now: Instant): Instant = now + length + } + + data class Custom(val at: Instant) : Deadline { + override fun endsAt(now: Instant): Instant = at + } + + companion object { + val Week: Deadline = Preset.Week + + /** The custom picker's bounds. */ + fun customRange(now: Instant): ClosedRange = (now + 1.hours)..(now + Preset.FourWeeks.length) + } + } + + companion object { + /** At most this many shares, the creator's included. */ + const val MAX_SHARES = 100 + + /** The creator first, then the rest of [roster] in order, each once. */ + fun members(viewerId: ID, roster: List): List = + listOf(viewerId) + roster.map { it.userId }.filter { it != viewerId }.distinct() + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt new file mode 100644 index 0000000000..0a0ee96c4c --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestCopy.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.messenger.internal.request + +import com.flipcash.features.messenger.R +import com.getcode.util.resources.ResourceHelper + +// The New Request summary's wording, out of Compose so the spec's cases can pin it. + +/** The People row's value. */ +internal fun peopleValue(people: NewRequestDraft.People, resources: ResourceHelper): String = when (people) { + NewRequestDraft.People.Choose -> resources.getString(R.string.value_requestPeople_choose) + NewRequestDraft.People.EveryoneIncludingYou -> resources.getString(R.string.value_requestPeople_everyoneIncludingYou) + NewRequestDraft.People.EveryoneExceptYou -> resources.getString(R.string.value_requestPeople_everyoneExceptYou) + is NewRequestDraft.People.SomeIncludingYou -> + resources.getQuantityString(R.plurals.value_requestPeople_someIncludingYou, people.others, people.others) + is NewRequestDraft.People.Some -> + resources.getQuantityString(R.plurals.value_requestPeople_some, people.count, people.count) +} + +/** The People row's second line: only when the creator is included. */ +internal fun peopleDetail(people: NewRequestDraft.People, resources: ResourceHelper): String? = + resources.getString(R.string.subtitle_requestMarkedPaid).takeIf { people.markedPaid } + +/** The summary's button, "Request from People", the creator not counted. */ +internal fun requestButtonText(draft: NewRequestDraft, amount: String, resources: ResourceHelper): String = + resources.getQuantityString(R.plurals.action_requestFromPeople, draft.requestedCount, draft.requestedCount, amount) + +/** The sheet for checking a 101st share: title and body. */ +internal fun maxPeopleNotice(resources: ResourceHelper): Pair = + resources.getString(R.string.title_requestMaxPeople) to resources.getString(R.string.description_requestMaxPeople) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt new file mode 100644 index 0000000000..2d8b3144b2 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/request/RequestDeck.kt @@ -0,0 +1,996 @@ +package com.flipcash.app.messenger.internal.request + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.AnimatedVisibilityScope +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout +import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.ResizeMode.Companion.scaleToBounds +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.Transition +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.scaleIn +import androidx.compose.animation.scaleOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.Canvas +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.detectTapGestures +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.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import kotlinx.coroutines.CoroutineScope +import androidx.compose.ui.unit.constrainWidth +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.Icons +import com.flipcash.shared.chat.ui.ActionCapsule +import com.flipcash.shared.chat.ui.ActionCapsuleButton +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.util.lerp +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.GraphicsLayerScope +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.withStyle +import com.flipcash.services.models.paymentrequest.StatusLine +import com.flipcash.services.models.paymentrequest.unpaidShareOf +import com.flipcash.services.models.paymentrequest.ViewerStanding +import com.flipcash.services.models.paymentrequest.viewerStanding +import com.flipcash.shared.chat.ui.request.PaidBadge +import com.flipcash.shared.chat.ui.request.ViewerStatusLine +import com.flipcash.shared.chat.ui.request.description +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.flipcash.core.R +import com.flipcash.services.models.paymentrequest.PaymentRequest +import com.flipcash.services.models.paymentrequest.PaymentRequestStatus +import com.flipcash.services.models.paymentrequest.PaymentRequestText +import com.flipcash.services.models.paymentrequest.statusLine +import com.flipcash.shared.chat.ui.request.PaymentRequestCardContent +import com.flipcash.shared.chat.ui.request.requestText +import com.flipcash.shared.chat.ui.request.text +import com.getcode.opencode.model.core.ID +import com.getcode.theme.CodeTheme +import kotlinx.coroutines.launch +import kotlin.math.roundToInt +import kotlin.time.Instant + +/** + * The shared-element scope the deck pill (in the app bar) and the expanded list (over the + * transcript) share, so the front card can move between them. Null outside [RequestDeckLayout]. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +private val LocalDeckTransition = compositionLocalOf { null } + +/** Hosts the screen so the deck's front card can move between the app bar and the expanded list. */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +internal fun RequestDeckLayout(modifier: Modifier = Modifier, content: @Composable BoxScope.() -> Unit) { + SharedTransitionLayout(modifier = modifier) { + CompositionLocalProvider(LocalDeckTransition provides this) { + Box(content = content) + } + } +} + +/** A deck request's title, or null for one the deck cannot draw (unsupported or not opened). */ +internal val PaymentRequest.deckTitle: String? + get() = (split?.text as? PaymentRequestText.Plain)?.title + +private fun sharedKey(id: ID, part: String) = "payment-request-${id.hashCode()}-$part" + +/** + * Ties one piece of the front card (the container, the title, the ring or the amounts) to its + * counterpart in the other state, so each moves on its own rather than fading with the card. + * Text scales to its new bounds, since the pill and the card set it in different styles. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun Modifier.deckShared( + id: ID, + part: String, + visibility: AnimatedVisibilityScope?, + shape: androidx.compose.ui.graphics.Shape? = null, +): Modifier { + val transition = LocalDeckTransition.current ?: return this + if (visibility == null) return this + return with(transition) { + val state = rememberSharedContentState(sharedKey(id, part)) + if (shape != null) { + this@deckShared.sharedBounds( + sharedContentState = state, + animatedVisibilityScope = visibility, + resizeMode = scaleToBounds(), + clipInOverlayDuringTransition = OverlayClip(shape), + ) + } else { + this@deckShared.sharedBounds( + sharedContentState = state, + animatedVisibilityScope = visibility, + resizeMode = scaleToBounds(), + ) + } + } +} + +/** Where the deck sits and how loud it is. */ +internal enum class DeckPlacement { + /** The grey pill in the app bar: the viewer owes nothing, or the strip has collapsed into it. */ + Pill, + + /** The full-width card under the app bar: the viewer owes. */ + Strip, + + /** Every request as a card over the transcript. */ + Expanded, +} + +/** + * The app-bar deck (concept B): the front request with up to two cards peeking beneath it. Swipe + * down cycles; tap expands. Shown while [transition] is at the pill; the front card moves from + * there to the strip and the expanded list as one shared element. + */ +@Composable +internal fun RequestDeckPill( + deck: List, + now: Instant, + viewerId: ID?, + transition: Transition, + onExpand: () -> Unit, + onCycle: () -> Unit, + modifier: Modifier = Modifier, +) { + // Held through the exit, so the pill doesn't empty as it fades out. + var shown by remember { mutableStateOf(deck) } + if (deck.isNotEmpty()) shown = deck + // Arriving and leaving: a fade and a slight scale in place, while the width eases so the title + // makes room. A deck that is there when the chat opens starts visible, so it doesn't fade. + AnimatedVisibility( + visible = deck.isNotEmpty(), + enter = fadeIn(tween(APPEAR_MILLIS)) + + scaleIn(tween(APPEAR_MILLIS), initialScale = APPEAR_SCALE) + + expandHorizontally(tween(APPEAR_MILLIS), expandFrom = Alignment.End, clip = false), + exit = fadeOut(tween(APPEAR_MILLIS)) + + scaleOut(tween(APPEAR_MILLIS), targetScale = APPEAR_SCALE) + + shrinkHorizontally(tween(APPEAR_MILLIS), shrinkTowards = Alignment.End, clip = false), + modifier = modifier, + ) { + DeckStack( + deck = shown, + now = now, + viewerId = viewerId, + style = DeckPlacement.Pill, + transition = transition, + onExpand = onExpand, + onCycle = onCycle, + onPay = {}, + ) + } +} + +/** + * The deck as a full-width card under the app bar (concept node 12670:1262), shown while the + * viewer owes and the timeline is at its newest messages. It keeps its room while [expanded], so + * the timeline doesn't move under the expanded cards, and gives it up as it leaves for the pill. + */ +@Composable +internal fun RequestDeckStrip( + deck: List, + now: Instant, + viewerId: ID?, + transition: Transition, + rest: DeckPlacement, + onExpand: () -> Unit, + onCycle: () -> Unit, + onPay: (PaymentRequest) -> Unit, + onHeightChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + var shown by remember { mutableStateOf(deck) } + if (deck.isNotEmpty()) shown = deck + // Linear, so a scroll that seeks it into the pill takes its height off at the scroll's pace. + transition.AnimatedVisibility( + visible = { + deck.isNotEmpty() && + (it == DeckPlacement.Strip || it == DeckPlacement.Expanded && rest == DeckPlacement.Strip) + }, + enter = expandVertically(tween(STRIP_MILLIS, easing = LinearEasing), expandFrom = Alignment.Top), + exit = shrinkVertically(tween(STRIP_MILLIS, easing = LinearEasing), shrinkTowards = Alignment.Top), + modifier = modifier, + ) { + Box( + modifier = Modifier + // Its whole height, padding included: the scroll that collapses it into the pill. + .onSizeChanged { onHeightChange(it.height) } + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset) + .padding(top = CodeTheme.dimens.grid.x2) + .height(DeckStyle.Strip.height + DeckStyle.Strip.step * 2), + ) { + DeckStack( + deck = shown, + now = now, + viewerId = viewerId, + style = DeckPlacement.Strip, + transition = transition, + onExpand = onExpand, + onCycle = onCycle, + onPay = onPay, + ) + } + } +} + +/** The stack's measurements in each placement. */ +private class DeckStyle( + val height: Dp, + val shape: RoundedCornerShape, + val inset: Dp, + val step: Dp, +) { + companion object { + val Pill = DeckStyle(height = 43.dp, shape = RoundedCornerShape(18.dp), inset = 7.dp, step = 5.dp) + val Strip = DeckStyle(height = 64.dp, shape = RoundedCornerShape(20.dp), inset = 8.dp, step = 6.dp) + } +} + +private val DeckPlacement.deckStyle: DeckStyle + get() = if (this == DeckPlacement.Strip) DeckStyle.Strip else DeckStyle.Pill + +/** + * The front request with up to two cards peeking beneath it, in one of the deck's placements. + * Swipe down cycles; tap expands. Shown while [transition] is at [style], whose scope its front + * card moves between placements in. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +private fun DeckStack( + deck: List, + now: Instant, + viewerId: ID?, + style: DeckPlacement, + transition: Transition, + onExpand: () -> Unit, + onCycle: () -> Unit, + onPay: (PaymentRequest) -> Unit, +) { + transition.AnimatedVisibility( + visible = { it == style }, + enter = fadeIn(tween(STRIP_MILLIS)), + exit = fadeOut(tween(STRIP_MILLIS)), + ) { + val front = deck.firstOrNull() ?: return@AnimatedVisibility + val geometry = style.deckStyle + val shape = geometry.shape + val strip = style == DeckPlacement.Strip + val transition = LocalDeckTransition.current + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val drag = remember { Animatable(0f) } + val arrive = remember { Animatable(1f) } + // Where the swipe was let go: how far the front card had travelled, and how far through + // the swipe that was. The hand-over starts every card from there. + var releaseDrag by remember { mutableFloatStateOf(0f) } + var releaseProgress by remember { mutableFloatStateOf(1f) } + val pillHeightPx = with(density) { geometry.height.toPx() } + val canCycle = deck.size > 1 + val pillWidth = remember(scope) { PillWidth(scope) } + + val label = deckLabel(deck, now, viewerId, strip) + val nextLabel = stringResource(R.string.action_nextRequest) + // The strip's Pay is a button of its own, so the deck's label goes on the rest of its card. + val deckSemantics = Modifier.clearAndSetSemantics { + contentDescription = label + role = Role.Button + onClick { onExpand(); true } + if (canCycle) customActions = listOf(CustomAccessibilityAction(nextLabel) { onCycle(); true }) + } + Box( + modifier = Modifier + .padding(bottom = geometry.step * 2) + .then(if (strip) Modifier else deckSemantics), + ) { + // How far the front card is through being swiped to the back, 0 to 1. The cards behind + // rise a slot with it, so the next request is already in place when the swipe lands. + val progress = (drag.value / pillHeightPx).coerceIn(0f, 1f) + val insetPx = with(density) { geometry.inset.toPx() } + val stepPx = with(density) { geometry.step.toPx() } + // The card just swiped off, travelling from below the pill to its slot at the back. + val swiped = if (canCycle && arrive.value < 1f) deck.last() else null + val swipedDepth = (deck.size - 1).coerceAtMost(2).toFloat() + // How far each card still has to rise after a hand-over: the swipe was let go short of + // a full pill height, so the cards behind finish the step on the same spring. + val handover = (1f - releaseProgress) * (1f - arrive.value) + + @Composable + fun Card(request: PaymentRequest, layer: GraphicsLayerScope.() -> Unit, depth: Float, content: Boolean) { + Box( + modifier = Modifier + .matchParentSize() + .graphicsLayer(layer) + // The request rising from behind is wider or narrower than the pill until + // the swipe is let go; it shows clipped to the card until then. + .clip(shape) + .background(CodeTheme.colors.background, shape) + .background(peekColor(style, depth), shape) + .border(1.dp, CodeTheme.colors.border, shape), + ) { + if (content) { + DeckCardFront( + request = request, + now = now, + viewerId = viewerId, + style = style, + visibility = null, + onPay = {}, + modifier = Modifier.fillMaxSize(), + ) + } + } + } + + if (swiped != null) { + Card( + request = swiped, + depth = swipedDepth * arrive.value, + content = true, + layer = { + val t = arrive.value + val scale = lerp(1f - DROP_SCALE * releaseProgress, slotScale(swipedDepth, insetPx, size.width), t) + scaleX = scale + scaleY = scale + translationY = lerp(releaseDrag, slotY(swipedDepth, scale, stepPx, size.height), t) + // Past the second slot it tucks out of sight rather than stacking up. + if (deck.size > 3) alpha = 1f - t + }, + ) + } + // The peeking cards, back first. Each is a scaled copy of the front slot, so the one + // rising into it grows with its glyph, title and amounts as one piece. + val peeks = deck.drop(1).take(3) + peeks.withIndex().reversed().forEach { (index, request) -> + if (request == swiped) return@forEach + val depth = index + 1 - progress + handover + // A third card behind only shows as the others move up. + if (depth >= 3f) return@forEach + Card( + request = request, + depth = depth, + // The card rising to the front shows its request as soon as the front moves. + content = index == 0 && drag.value > 0f, + layer = { + val scale = slotScale(depth, insetPx, size.width) + scaleX = scale + scaleY = scale + translationY = slotY(depth, scale, stepPx, size.height) + if (depth > 2f) alpha = 1f - (depth - 2f) + }, + ) + } + DeckCardFront( + request = front, + now = now, + viewerId = viewerId, + style = style, + visibility = this@AnimatedVisibility, + onPay = { onPay(front) }, + bodyModifier = if (strip) deckSemantics else Modifier, + modifier = Modifier + .then(if (strip) Modifier.fillMaxWidth() else pillWidth.modifier) + .then( + if (transition != null) with(transition) { + Modifier.sharedBounds( + sharedContentState = rememberSharedContentState(sharedKey(front.id, "card")), + animatedVisibilityScope = this@AnimatedVisibility, + clipInOverlayDuringTransition = OverlayClip(shape), + ) + } else Modifier + ) + .graphicsLayer { + // Falls back as it goes, so it hands off at the size the back card starts from. + // Just after a hand-over it is still rising out of the slot behind. + val scale = (1f - DROP_SCALE * progress) * slotScale(handover, insetPx, size.width) + scaleX = scale + scaleY = scale + translationY = drag.value + slotY(handover, scale, stepPx, size.height) + } + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { delta -> + scope.launch { + val value = drag.value + val next = if (!canCycle) { + // One request: a small elastic pull and nothing more. + (value + delta * 0.3f).coerceIn(0f, pillHeightPx * 0.25f) + } else if (delta > 0f && value >= pillHeightPx) { + // Past one pill height the card rubber-bands instead of + // sliding on into the chat. + val over = (value - pillHeightPx) / (pillHeightPx * RUBBER_BAND) + value + delta * 0.5f * (1f - over).coerceAtLeast(0f) + } else { + (value + delta).coerceAtLeast(0f) + } + drag.snapTo(next) + } + }, + onDragStopped = { velocity -> + val past = drag.value > pillHeightPx * CYCLE_THRESHOLD || velocity > FLING_VELOCITY + if (canCycle && past) { + // Hand over on release: the card that rose becomes the front and the + // swiped one drops behind to the back, each from where it was, while + // the pill eases to the new front's width on the same spring. + releaseDrag = drag.value + releaseProgress = (drag.value / pillHeightPx).coerceIn(0f, 1f) + drag.snapTo(0f) + arrive.snapTo(0f) + onCycle() + arrive.animateTo(1f, DECK_SPRING) + } else { + drag.animateTo(0f, DECK_SPRING, initialVelocity = velocity) + } + }, + ) + .clip(shape) + .clickable(onClick = onExpand), + ) + } + } +} + +/** A peeking card's fill at [depth]: the front's surface one slot back, fading toward the next. */ +@Composable +private fun peekColor(style: DeckPlacement, depth: Float): Color { + val back = (depth - 1f).coerceIn(0f, 1f) + return when (style) { + DeckPlacement.Pill, DeckPlacement.Expanded -> lerp(CodeTheme.colors.surfaceVariant, CodeTheme.colors.surface, back) + DeckPlacement.Strip -> lerp(CodeTheme.colors.surfaceVariant, Color.White.copy(alpha = 0.08f), back) + } +} + +/** The front card's face in [style]. [bodyModifier] goes on everything but the strip's Pay button. */ +@Composable +private fun DeckCardFront( + request: PaymentRequest, + now: Instant, + viewerId: ID?, + style: DeckPlacement, + visibility: AnimatedVisibilityScope?, + onPay: () -> Unit, + modifier: Modifier = Modifier, + bodyModifier: Modifier = Modifier, +) { + when (style) { + DeckPlacement.Pill, DeckPlacement.Expanded -> DeckFront(request = request, now = now, viewerId = viewerId, visibility = visibility, modifier = modifier) + DeckPlacement.Strip -> StripFront(request, now, viewerId, visibility, onPay, modifier, bodyModifier) + } +} + +/** + * The strip's front card (concept node 12670:1262): glyph, title, "You owe ·