diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt index f0d2d3f06..cf04d946e 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/screens/ContactListScreen.kt @@ -5,11 +5,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.AnchoredDraggableState -import androidx.compose.foundation.gestures.DraggableAnchors -import androidx.compose.foundation.gestures.Orientation -import androidx.compose.foundation.gestures.anchoredDraggable -import androidx.compose.foundation.gestures.snapTo import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope @@ -19,7 +14,6 @@ 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.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.size @@ -27,33 +21,25 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.GroupAdd +import androidx.compose.material.icons.rounded.PersonRemove import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect 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.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable 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.clipToBounds import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.layout.layout -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign @@ -84,14 +70,14 @@ import com.getcode.ui.components.AppBarDefaults import com.getcode.ui.components.AppBarWithTitle import com.getcode.ui.components.CircularIconButton import com.getcode.ui.components.SearchInput +import com.getcode.ui.components.SwipeAction +import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.core.rememberedClickable import com.getcode.ui.core.verticalScrollStateGradient import com.getcode.ui.theme.CodeCircularProgressIndicator import com.getcode.ui.theme.CodeScaffold import com.getcode.view.LoadingSuccessState -import kotlin.math.abs import kotlinx.coroutines.flow.filterIsInstance -import kotlinx.coroutines.launch @Composable @@ -264,16 +250,42 @@ private fun ContactList( items[index + 1] is ContactListItem.Header if (isPickerMode) { - SwipeToRevealItem( - modifier = Modifier.animateItem(), - onDelete = { onItemDismissed(item) }, - ) { - ContactRowItem( - contact = item.contact, - isOnFlipcash = item.isOnFlipcash, - showDivider = !isLastInSection, - onClick = { onItemClick(item) }, - ) + Column(modifier = Modifier.animateItem()) { + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.error, + onTriggered = { onItemDismissed(item) }, + ) { + Icon( + imageVector = Icons.Rounded.PersonRemove, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + ), + stateKey = item.contact.e164, + ) { + ContactRowItem( + contact = item.contact, + isOnFlipcash = item.isOnFlipcash, + showDivider = false, + onClick = { onItemClick(item) }, + ) + } + if (!isLastInSection) { + HorizontalDivider( + color = CodeTheme.colors.divider, + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding( + start = CodeTheme.dimens.inset + CodeTheme.dimens.staticGrid.x8 + CodeTheme.dimens.grid.x3, + end = CodeTheme.dimens.inset, + ), + ) + } } } else { ContactRowItem( @@ -290,101 +302,6 @@ private fun ContactList( } } -private enum class RevealValue { Settled, Revealed, Dismissed } - -@Composable -private fun SwipeToRevealItem( - onDelete: () -> Unit, - modifier: Modifier = Modifier, - content: @Composable () -> Unit, -) { - val density = LocalDensity.current - val scope = rememberCoroutineScope() - val actionWidth = CodeTheme.dimens.staticGrid.x10 + CodeTheme.dimens.inset * 2 - val actionWidthPx = with(density) { actionWidth.toPx() } - - val state = remember { AnchoredDraggableState(initialValue = RevealValue.Settled) } - var rowWidthPx by remember { mutableFloatStateOf(0f) } - - LaunchedEffect(rowWidthPx, actionWidthPx) { - if (rowWidthPx > 0f) { - state.updateAnchors(DraggableAnchors { - RevealValue.Settled at 0f - RevealValue.Revealed at -actionWidthPx - RevealValue.Dismissed at -rowWidthPx - }) - } - } - - val currentOnDelete by rememberUpdatedState(onDelete) - LaunchedEffect(state.currentValue) { - if (state.currentValue == RevealValue.Dismissed) { - currentOnDelete() - } - } - - val actionPadding = CodeTheme.dimens.inset - val minActionSize = CodeTheme.dimens.staticGrid.x10 - - Box( - modifier = modifier - .clipToBounds() - .onSizeChanged { rowWidthPx = it.width.toFloat() }, - ) { - // Action area — grows from circle to rounded rect as swipe progresses - Box( - modifier = Modifier - .matchParentSize() - .layout { measurable, constraints -> - val absOffset = abs(state.offset) - val paddingPx = actionPadding.roundToPx() - val minPx = minActionSize.roundToPx() - - val w = (absOffset.toInt() - paddingPx * 2).coerceAtLeast(minPx) - val h = (constraints.maxHeight - paddingPx * 2).coerceAtLeast(minPx) - - val placeable = measurable.measure( - constraints.copy( - minWidth = w, maxWidth = w, - minHeight = h, maxHeight = h, - ) - ) - layout(constraints.maxWidth, constraints.maxHeight) { - placeable.place( - constraints.maxWidth - placeable.width - paddingPx, - (constraints.maxHeight - placeable.height) / 2, - ) - } - } - .clip(RoundedCornerShape(50)) - .background(CodeTheme.colors.error) - .rememberedClickable { - scope.launch { - state.snapTo(RevealValue.Settled) - } - onDelete() - }, - contentAlignment = Alignment.Center, - ) { - Icon( - painter = painterResource(R.drawable.ic_delete), - contentDescription = stringResource(R.string.action_remove), - tint = Color.White, - modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), - ) - } - - // Foreground content that slides - Box( - modifier = Modifier - .offset { IntOffset(state.offset.toInt(), 0) } - .anchoredDraggable(state, Orientation.Horizontal), - ) { - content() - } - } -} - @Composable private fun ContactGroupHeader(text: String, modifier: Modifier = Modifier) { Box( diff --git a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt index b11804702..e365851c0 100644 --- a/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt +++ b/apps/flipcash/shared/currency-selection/ui/src/main/kotlin/com/flipcash/app/currency/internal/components/ListRowItem.kt @@ -2,7 +2,6 @@ package com.flipcash.app.currency.internal.components import androidx.compose.foundation.Image 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 @@ -11,23 +10,10 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.DismissDirection -import androidx.compose.material.DismissState -import androidx.compose.material.DismissValue -import androidx.compose.material.Divider -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.FixedThreshold -import androidx.compose.material.Icon -import androidx.compose.material.SwipeToDismiss -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text import androidx.compose.runtime.Composable -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.draw.alpha @@ -37,10 +23,9 @@ import androidx.compose.ui.unit.dp import com.flipcash.app.currency.internal.CurrencyListItem import com.flipcash.features.currency.R import com.getcode.theme.CodeTheme +import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.core.rememberedClickable -import kotlinx.coroutines.delay -@OptIn(ExperimentalMaterialApi::class) @Composable internal fun ListRowItem( item: CurrencyListItem.RegionCurrencyItem, @@ -49,34 +34,7 @@ internal fun ListRowItem( onRemoved: () -> Unit, onClick: () -> Unit ) { - - var removed by remember(item) { - mutableStateOf(false) - } - - val dismissState = remember(item) { - DismissState( - initialValue = DismissValue.Default, - confirmStateChange = { - if (it == DismissValue.DismissedToStart) { - removed = true - true - } else false - } - ) - } - - SwipeToDismiss( - modifier = modifier, - state = dismissState, - dismissThresholds = { FixedThreshold(150.dp) }, - directions = if (item.isRecent) setOf(DismissDirection.EndToStart) else emptySet(), - background = { - if (item.isRecent) { - DismissBackground(dismissState) - } - } - ) { + val rowContent: @Composable () -> Unit = { Box( modifier = Modifier .fillMaxSize() @@ -133,48 +91,37 @@ internal fun ListRowItem( contentDescription = "" ) } + } + } - Divider( + if (item.isRecent) { + Column(modifier = modifier) { + SwipeActionRow( + modifier = Modifier.weight(1f), + onDelete = onRemoved, + stateKey = item.currency.code, + ) { + rowContent() + } + HorizontalDivider( color = CodeTheme.colors.dividerVariant, modifier = Modifier .fillMaxWidth() .height(1.dp) - .align(Alignment.BottomCenter) .padding(start = CodeTheme.dimens.inset) ) } - } - - LaunchedEffect(removed) { - if (removed) { - delay(200) - onRemoved() - } - } -} - -@OptIn(ExperimentalMaterialApi::class) -@Composable -private fun DismissBackground(dismissState: DismissState) { - val color = when (dismissState.dismissDirection) { - DismissDirection.EndToStart -> CodeTheme.colors.error - else -> CodeTheme.colors.background - } - val direction = dismissState.dismissDirection - - Row( - modifier = Modifier - .fillMaxSize() - .background(color) - .padding(end = CodeTheme.dimens.inset), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.End - ) { - if (direction == DismissDirection.EndToStart) { - Icon( - modifier = Modifier.size(CodeTheme.dimens.staticGrid.x6), - painter = painterResource(id = R.drawable.ic_delete), - contentDescription = "delete" + } else { + Column(modifier = modifier) { + Box(modifier = Modifier.weight(1f)) { + rowContent() + } + HorizontalDivider( + color = CodeTheme.colors.dividerVariant, + modifier = Modifier + .fillMaxWidth() + .height(1.dp) + .padding(start = CodeTheme.dimens.inset) ) } } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt new file mode 100644 index 000000000..7e551652d --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt @@ -0,0 +1,304 @@ +package com.getcode.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.AnchoredDraggableState +import androidx.compose.foundation.gestures.DraggableAnchors +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.anchoredDraggable +import androidx.compose.foundation.gestures.snapTo +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Edit +import androidx.compose.material.icons.rounded.DeleteOutline +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState +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.clipToBounds +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.IntOffset +import com.getcode.theme.CodeTheme +import com.getcode.theme.DesignSystem +import com.getcode.theme.White50 +import com.getcode.ui.core.rememberedClickable +import com.getcode.util.resources.R +import kotlin.math.abs +import kotlinx.coroutines.launch + +/** + * Describes a single action revealed behind the row content on swipe. + * + * @param background Background color of the action button. + * @param onTriggered Callback when the action is tapped, or when a full swipe + * triggers the rightmost action automatically. + * @param content Composable content displayed inside the action button (typically an [Icon]). + */ +class SwipeAction( + val background: Color, + val onTriggered: () -> Unit, + val content: @Composable () -> Unit, +) + +private enum class SwipeState { Settled, Revealed, Dismissed } + +/** + * Convenience overload that wraps [content] with a single red delete action. + */ +@Composable +fun SwipeActionRow( + onDelete: () -> Unit, + modifier: Modifier = Modifier, + stateKey: Any? = null, + content: @Composable () -> Unit, +) { + SwipeActionRow( + actions = listOf( + SwipeAction( + background = CodeTheme.colors.error, + onTriggered = onDelete, + ) { + Icon( + painter = painterResource(R.drawable.ic_delete), + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + ), + modifier = modifier, + stateKey = stateKey, + content = content, + ) +} + +/** + * A swipe-to-reveal row that reveals one or more [actions] behind the foreground [content]. + * + * Swiping left reveals all actions. The **last** action in the list occupies the + * rightmost position, grows from a circle into a rounded rectangle as the swipe + * progresses, and is automatically triggered on a full swipe (dismiss). All other + * actions remain fixed-size circles and are click-only. + */ +@Composable +fun SwipeActionRow( + actions: List, + modifier: Modifier = Modifier, + stateKey: Any? = null, + initiallyRevealed: Boolean = false, + content: @Composable () -> Unit, +) { + if (actions.isEmpty()) { + Box(modifier) { content() } + return + } + + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val actionPadding = CodeTheme.dimens.inset + val minActionSize = CodeTheme.dimens.staticGrid.x10 + + // Each action slot = minSize + padding on each side. + // Total reveal = N slots + one extra padding on the outer edge. + val totalRevealWidth = (minActionSize + actionPadding) * actions.size + actionPadding + val totalRevealWidthPx = with(density) { totalRevealWidth.toPx() } + + val initialState = if (initiallyRevealed) SwipeState.Revealed else SwipeState.Settled + val state = remember(stateKey) { AnchoredDraggableState(initialValue = initialState) } + var rowWidthPx by remember(stateKey) { mutableFloatStateOf(0f) } + + LaunchedEffect(rowWidthPx, totalRevealWidthPx) { + if (rowWidthPx > 0f) { + state.updateAnchors(DraggableAnchors { + SwipeState.Settled at 0f + SwipeState.Revealed at -totalRevealWidthPx + SwipeState.Dismissed at -rowWidthPx + }) + } + } + + val dismissAction = actions.last() + val currentDismissCallback by rememberUpdatedState(dismissAction.onTriggered) + LaunchedEffect(state.currentValue) { + if (state.currentValue == SwipeState.Dismissed) { + currentDismissCallback() + } + } + + Box( + modifier = modifier + .clipToBounds() + .onSizeChanged { rowWidthPx = it.width.toFloat() }, + ) { + // Actions panel behind the foreground content + val actionPaddingDp = actionPadding + val minActionSizeDp = minActionSize + + Layout( + content = { + actions.forEachIndexed { index, action -> + Box( + modifier = Modifier + .clip(RoundedCornerShape(50)) + .background(action.background) + .rememberedClickable { + scope.launch { state.snapTo(SwipeState.Settled) } + action.onTriggered() + }, + contentAlignment = Alignment.Center, + ) { + action.content() + } + } + }, + modifier = Modifier.matchParentSize(), + ) { measurables, constraints -> + val absOffset = abs(state.offset) + val padPx = actionPaddingDp.roundToPx() + val minPx = minActionSizeDp.roundToPx() + val n = measurables.size + val actionH = (constraints.maxHeight - padPx * 2).coerceAtLeast(minPx) + + // Non-dismiss actions each occupy a fixed slot: padding + minSize. + // The dismiss action (last) gets the remaining width and grows on full swipe. + val fixedSlotWidth = minPx + padPx + val nonDismissTotal = (n - 1) * fixedSlotWidth + val dismissAvailable = (absOffset - padPx - nonDismissTotal).coerceAtLeast(0f) + val dismissW = (dismissAvailable.toInt() - padPx).coerceAtLeast(minPx) + + val placeables = measurables.mapIndexed { index, measurable -> + val w = if (index == n - 1) dismissW else minPx + measurable.measure(Constraints.fixed(w, actionH)) + } + + layout(constraints.maxWidth, constraints.maxHeight) { + val yCenter = (constraints.maxHeight - actionH) / 2 + var x = constraints.maxWidth - padPx + + // Dismiss action (rightmost) + x -= placeables.last().width + placeables.last().place(x, yCenter) + + // Remaining actions, right to left + for (i in n - 2 downTo 0) { + x -= padPx + placeables[i].width + placeables[i].place(x, yCenter) + } + } + } + + // Foreground content that slides + Box( + modifier = Modifier + .offset { IntOffset(state.offset.toInt(), 0) } + .anchoredDraggable(state, Orientation.Horizontal), + ) { + content() + } + } +} + +@Preview +@Composable +private fun SwipeActionRowPreview() { + DesignSystem { + Column( + modifier = Modifier + .background(CodeTheme.colors.background) + .padding(vertical = CodeTheme.dimens.grid.x4), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + val editAction = SwipeAction( + background = White50, + onTriggered = {}, + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + val deleteAction = SwipeAction( + background = CodeTheme.colors.error, + onTriggered = {}, + ) { + Icon( + imageVector = Icons.Rounded.DeleteOutline, + contentDescription = null, + tint = Color.White, + modifier = Modifier.requiredSize(CodeTheme.dimens.staticGrid.x5), + ) + } + + // Two actions — revealed + SwipeActionRow( + actions = listOf(editAction, deleteAction), + initiallyRevealed = true, + ) { + FakeRowContent(title = "Swiped row with two actions", subtitle = "edit + delete") + } + + // Single action — settled (swipe to interact) + SwipeActionRow(onDelete = {}) { + FakeRowContent(title = "Single delete action", subtitle = "swipe left to reveal") + } + } + } +} + +@Composable +private fun FakeRowContent(title: String, subtitle: String) { + Row( + modifier = Modifier + .fillMaxWidth() + .background(CodeTheme.colors.background) + .padding(CodeTheme.dimens.inset), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x10) + .clip(RoundedCornerShape(CodeTheme.dimens.grid.x3)) + .background(White50), + ) + Spacer(Modifier.width(CodeTheme.dimens.grid.x3)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.textMain, + ) + Text( + text = subtitle, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + } +}