From 143bb947fcb238dcf9b0de4deac449b54df4dc48 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 3 Jun 2026 13:38:04 -0400 Subject: [PATCH 1/2] style(region-selection): update selection UI to match new UI/UX in ContactList for swipe-to-reveal/dismiss Signed-off-by: Brandon McAnsh --- .../internal/screens/ContactListScreen.kt | 113 +-------------- .../internal/components/ListRowItem.kt | 85 ++--------- .../ui/components/SwipeToRevealItem.kt | 136 ++++++++++++++++++ 3 files changed, 148 insertions(+), 186 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt 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..d8106580d 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,7 +21,6 @@ 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.material3.HorizontalDivider @@ -36,24 +29,16 @@ 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 +69,13 @@ 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.SwipeToRevealItem 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 @@ -290,101 +274,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..8de2db6f5 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.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.SwipeToRevealItem 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() @@ -145,37 +103,16 @@ internal fun ListRowItem( } } - LaunchedEffect(removed) { - if (removed) { - delay(200) - onRemoved() + if (item.isRecent) { + SwipeToRevealItem( + modifier = modifier, + onDelete = onRemoved, + ) { + rowContent() } - } -} - -@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 { + Box(modifier = modifier) { + rowContent() } } } \ No newline at end of file diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt new file mode 100644 index 000000000..04d81f503 --- /dev/null +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt @@ -0,0 +1,136 @@ +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.Box +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +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.unit.IntOffset +import com.getcode.theme.CodeTheme +import com.getcode.ui.core.rememberedClickable +import com.getcode.util.resources.R +import kotlin.math.abs +import kotlinx.coroutines.launch + +private enum class RevealValue { Settled, Revealed, Dismissed } + +/** + * A swipe-to-reveal container that reveals a delete action behind the content. + * Swiping left reveals a growing circular delete button; swiping past the threshold + * dismisses the item and triggers [onDelete]. + */ +@Composable +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 = null, + 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() + } + } +} From fdeb673bf55a2d72316c8efeb7f80e63f4999fc8 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 3 Jun 2026 14:09:26 -0400 Subject: [PATCH 2/2] refactor(ui): extract SwipeActionRow with multi-action support Rename SwipeToRevealItem to SwipeActionRow and move to ui:components as a shared composable. Support N actions via SwipeAction, where the rightmost action grows on full swipe and triggers automatically. Add stateKey parameter to prevent swipe state carryover across list item removals. Move dividers outside swipeable content so they remain static during swipe. Signed-off-by: Brandon McAnsh --- .../internal/screens/ContactListScreen.kt | 50 ++- .../internal/components/ListRowItem.kt | 42 ++- .../getcode/ui/components/SwipeActionRow.kt | 304 ++++++++++++++++++ .../ui/components/SwipeToRevealItem.kt | 136 -------- 4 files changed, 369 insertions(+), 163 deletions(-) create mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/SwipeActionRow.kt delete mode 100644 ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt 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 d8106580d..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 @@ -23,6 +23,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape 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 @@ -69,7 +70,8 @@ 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.SwipeToRevealItem +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 @@ -248,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( 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 8de2db6f5..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 @@ -11,8 +11,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.wrapContentWidth -import androidx.compose.material.Divider -import androidx.compose.material.Text +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -23,7 +23,7 @@ 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.SwipeToRevealItem +import com.getcode.ui.components.SwipeActionRow import com.getcode.ui.core.rememberedClickable @Composable @@ -91,28 +91,38 @@ 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) ) } - } - - if (item.isRecent) { - SwipeToRevealItem( - modifier = modifier, - onDelete = onRemoved, - ) { - rowContent() - } } else { - Box(modifier = modifier) { - rowContent() + 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) + ) } } } \ No newline at end of file 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, + ) + } + } +} diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt deleted file mode 100644 index 04d81f503..000000000 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/SwipeToRevealItem.kt +++ /dev/null @@ -1,136 +0,0 @@ -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.Box -import androidx.compose.foundation.layout.offset -import androidx.compose.foundation.layout.requiredSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -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.unit.IntOffset -import com.getcode.theme.CodeTheme -import com.getcode.ui.core.rememberedClickable -import com.getcode.util.resources.R -import kotlin.math.abs -import kotlinx.coroutines.launch - -private enum class RevealValue { Settled, Revealed, Dismissed } - -/** - * A swipe-to-reveal container that reveals a delete action behind the content. - * Swiping left reveals a growing circular delete button; swiping past the threshold - * dismisses the item and triggers [onDelete]. - */ -@Composable -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 = null, - 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() - } - } -}