From f070f8231951abe24bdfc2728383c0b272a42473 Mon Sep 17 00:00:00 2001 From: mdrlzy Date: Sun, 22 Dec 2024 06:58:24 +0600 Subject: [PATCH] Switch input/output with drag --- .../rate/core/presentation/theme/ArkColor.kt | 2 + .../rate/core/presentation/utils/Haptic.kt | 53 +++ .../src/main/res/drawable/ic_drag.xml | 30 ++ feature/quick/build.gradle.kts | 1 + .../quick/presentation/add/AddQuickScreen.kt | 339 ++++++++++++------ .../presentation/add/AddQuickViewModel.kt | 13 + gradle/libs.versions.toml | 2 + 7 files changed, 332 insertions(+), 108 deletions(-) create mode 100644 core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/utils/Haptic.kt create mode 100644 core/presentation/src/main/res/drawable/ic_drag.xml diff --git a/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/theme/ArkColor.kt b/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/theme/ArkColor.kt index 8d3cc6438..760b68402 100644 --- a/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/theme/ArkColor.kt +++ b/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/theme/ArkColor.kt @@ -40,4 +40,6 @@ object ArkColor { val UtilitySuccess200 = Color(0xFFABEFC6) val UtilitySuccess500 = Color(0xFF17B26A) val UtilitySuccess700 = Color(0xFF067647) + + val NeutralGray500 = Color(0xFF6C737F) } diff --git a/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/utils/Haptic.kt b/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/utils/Haptic.kt new file mode 100644 index 000000000..d1fdadf54 --- /dev/null +++ b/core/presentation/src/main/java/dev/arkbuilders/rate/core/presentation/utils/Haptic.kt @@ -0,0 +1,53 @@ +package dev.arkbuilders.rate.core.presentation.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalView +import androidx.core.view.HapticFeedbackConstantsCompat +import androidx.core.view.ViewCompat + +enum class ReorderHapticFeedbackType { + START, + MOVE, + END, +} + +open class ReorderHapticFeedback { + open fun performHapticFeedback(type: ReorderHapticFeedbackType) { + // no-op + } +} + +@Composable +fun rememberReorderHapticFeedback(): ReorderHapticFeedback { + val view = LocalView.current + + val reorderHapticFeedback = + remember { + object : ReorderHapticFeedback() { + override fun performHapticFeedback(type: ReorderHapticFeedbackType) { + when (type) { + ReorderHapticFeedbackType.START -> + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_START, + ) + + ReorderHapticFeedbackType.MOVE -> + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.SEGMENT_FREQUENT_TICK, + ) + + ReorderHapticFeedbackType.END -> + ViewCompat.performHapticFeedback( + view, + HapticFeedbackConstantsCompat.GESTURE_END, + ) + } + } + } + } + + return reorderHapticFeedback +} diff --git a/core/presentation/src/main/res/drawable/ic_drag.xml b/core/presentation/src/main/res/drawable/ic_drag.xml new file mode 100644 index 000000000..443f26407 --- /dev/null +++ b/core/presentation/src/main/res/drawable/ic_drag.xml @@ -0,0 +1,30 @@ + + + + + + + + diff --git a/feature/quick/build.gradle.kts b/feature/quick/build.gradle.kts index 35017441b..b9ffb11c2 100644 --- a/feature/quick/build.gradle.kts +++ b/feature/quick/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.activity.compose) implementation(libs.constraintlayout.compose) + implementation(libs.reorderable) implementation(libs.timber) diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickScreen.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickScreen.kt index 2fa235f13..2f6b17b47 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickScreen.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickScreen.kt @@ -1,8 +1,10 @@ -@file:OptIn(ExperimentalMaterial3Api::class) +@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) package dev.arkbuilders.rate.feature.quick.presentation.add import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll @@ -16,11 +18,15 @@ 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.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api @@ -42,6 +48,7 @@ import androidx.compose.ui.layout.onPlaced import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType @@ -69,10 +76,17 @@ import dev.arkbuilders.rate.core.presentation.ui.DropDownWithIcon import dev.arkbuilders.rate.core.presentation.ui.GroupCreateDialog import dev.arkbuilders.rate.core.presentation.ui.GroupSelectPopup import dev.arkbuilders.rate.core.presentation.ui.NotifyAddedSnackbarVisuals +import dev.arkbuilders.rate.core.presentation.utils.ReorderHapticFeedback +import dev.arkbuilders.rate.core.presentation.utils.ReorderHapticFeedbackType +import dev.arkbuilders.rate.core.presentation.utils.rememberReorderHapticFeedback import dev.arkbuilders.rate.feature.quick.di.QuickComponentHolder import dev.arkbuilders.rate.feature.search.presentation.destinations.SearchCurrencyScreenDestination import org.orbitmvi.orbit.compose.collectAsState import org.orbitmvi.orbit.compose.collectSideEffect +import sh.calvin.reorderable.ReorderableCollectionItemScope +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.ReorderableLazyListState +import sh.calvin.reorderable.rememberReorderableLazyListState @Composable @Destination @@ -153,6 +167,7 @@ fun AddQuickScreen( ) }, onSwapClick = viewModel::onSwapClick, + onPairsSwap = viewModel::onPairsSwap, onAddAsset = viewModel::onAddQuickPair, ) } @@ -169,6 +184,7 @@ private fun Content( onGroupSelect: (String) -> Unit = {}, onCodeChange: (Int) -> Unit = {}, onSwapClick: () -> Unit = {}, + onPairsSwap: (from: Int, to: Int) -> Unit = { _, _ -> }, onAddAsset: () -> Unit = {}, ) { var showNewGroupDialog by remember { mutableStateOf(false) } @@ -181,78 +197,98 @@ private fun Content( } } + val haptic = rememberReorderHapticFeedback() + val lazyListState = rememberLazyListState() + val reorderableLazyColumnState = + rememberReorderableLazyListState(lazyListState) { from, to -> + val fromIndex = state.currencies.indexOfFirst { it.code == from.key } + val toIndex = state.currencies.indexOfFirst { it.code == to.key } + onPairsSwap(fromIndex, toIndex) + haptic.performHapticFeedback(ReorderHapticFeedbackType.MOVE) + } + Column(modifier = Modifier.fillMaxSize()) { - Column( + LazyColumn( modifier = Modifier - .weight(1f) - .verticalScroll(rememberScrollState()), + .weight(1f), + state = lazyListState, ) { - Currencies(state, onAmountChanged, onCurrencyRemove, onCodeChange, onSwapClick) - Button( - modifier = Modifier.padding(start = 16.dp, top = 16.dp), - shape = RoundedCornerShape(8.dp), - border = BorderStroke(1.dp, ArkColor.Border), - colors = - ButtonDefaults.buttonColors( - containerColor = Color.Transparent, - contentColor = ArkColor.FGSecondary, - ), - onClick = { onNewCurrencyClick() }, - contentPadding = PaddingValues(0.dp), - ) { - Icon( - modifier = Modifier.padding(start = 20.dp), - painter = painterResource(id = R.drawable.ic_add), - contentDescription = "", - ) - Text( - modifier = - Modifier.padding( - start = 8.dp, - top = 10.dp, - bottom = 10.dp, - end = 18.dp, - ), - text = stringResource(R.string.new_currency), - fontWeight = FontWeight.SemiBold, - fontSize = 16.sp, - ) - } - DropDownWithIcon( - modifier = - Modifier - .fillMaxWidth() - .padding(top = 16.dp, start = 16.dp, end = 16.dp) - .onPlaced { - addGroupBtnWidth = it.size.width - }, - onClick = { showGroupsPopup = true }, - title = - state.group?.let { state.group } - ?: stringResource(R.string.add_group), - icon = painterResource(id = R.drawable.ic_group), + currencies( + state, + reorderableLazyColumnState, + haptic, + onAmountChanged, + onCurrencyRemove, + onCodeChange, + onSwapClick, ) - if (showGroupsPopup) { - Box( - modifier = - Modifier.padding( - start = 16.dp, - top = 4.dp, + item { + Button( + modifier = Modifier.padding(start = 16.dp, top = 16.dp), + shape = RoundedCornerShape(8.dp), + border = BorderStroke(1.dp, ArkColor.Border), + colors = + ButtonDefaults.buttonColors( + containerColor = Color.Transparent, + contentColor = ArkColor.FGSecondary, ), + onClick = { onNewCurrencyClick() }, + contentPadding = PaddingValues(0.dp), ) { - Popup( - offset = IntOffset(0, 0), - properties = PopupProperties(), - onDismissRequest = { showGroupsPopup = false }, + Icon( + modifier = Modifier.padding(start = 20.dp), + painter = painterResource(id = R.drawable.ic_add), + contentDescription = "", + ) + Text( + modifier = + Modifier.padding( + start = 8.dp, + top = 10.dp, + bottom = 10.dp, + end = 18.dp, + ), + text = stringResource(R.string.new_currency), + fontWeight = FontWeight.SemiBold, + fontSize = 16.sp, + ) + } + DropDownWithIcon( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 16.dp, start = 16.dp, end = 16.dp) + .onPlaced { + addGroupBtnWidth = it.size.width + }, + onClick = { showGroupsPopup = true }, + title = + state.group?.let { state.group } + ?: stringResource(R.string.add_group), + icon = painterResource(id = R.drawable.ic_group), + ) + if (showGroupsPopup) { + Box( + modifier = + Modifier.padding( + start = 16.dp, + top = 4.dp, + ), ) { - GroupSelectPopup( - groups = state.availableGroups, - widthPx = addGroupBtnWidth, - onGroupSelect = { onGroupSelect(it) }, - onNewGroupClick = { showNewGroupDialog = true }, - onDismiss = { showGroupsPopup = false }, - ) + Popup( + offset = IntOffset(0, 0), + properties = PopupProperties(), + onDismissRequest = { showGroupsPopup = false }, + ) { + GroupSelectPopup( + groups = state.availableGroups, + widthPx = addGroupBtnWidth, + onGroupSelect = { onGroupSelect(it) }, + onNewGroupClick = { showNewGroupDialog = true }, + onDismiss = { showGroupsPopup = false }, + ) + } } } } @@ -275,62 +311,110 @@ private fun Content( } } -@Composable -private fun Currencies( +private fun LazyListScope.currencies( state: AddQuickScreenState, + reorderableLazyColumnState: ReorderableLazyListState, + haptic: ReorderHapticFeedback, onAmountChanged: (String) -> Unit, onCurrencyRemove: (Int) -> Unit, onCodeChange: (Int) -> Unit, onSwapClick: () -> Unit, ) { val from = state.currencies.first() - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp), - text = "From", - fontWeight = FontWeight.Medium, - color = ArkColor.TextSecondary, - ) - FromInput( - index = 0, - code = from.code, - amount = from.value, - onAmountChanged = onAmountChanged, - onCodeChange = onCodeChange, - ) - SwapBtn(modifier = Modifier.padding(top = 16.dp), onClick = onSwapClick) - Text( - modifier = Modifier.padding(top = 16.dp, start = 16.dp), - text = "To", - fontWeight = FontWeight.Medium, - color = ArkColor.TextSecondary, - ) - state.currencies.forEachIndexed { index, amountStr -> - if (index == 0) - return@forEachIndexed + val to = state.currencies.drop(1) - ToResult( - index = index, - code = amountStr.code, - amount = amountStr.value, - onCurrencyRemove = onCurrencyRemove, - onCodeChange = onCodeChange, + item { + Text( + modifier = Modifier.padding(top = 16.dp, start = 52.dp), + text = "From", + fontWeight = FontWeight.Medium, + color = ArkColor.TextSecondary, ) } + item(key = from.code) { + ReorderableItem(state = reorderableLazyColumnState, key = from.code) { + FromInput( + code = from.code, + amount = from.value, + haptic = haptic, + scope = this, + onAmountChanged = onAmountChanged, + onCodeChange = { + val index = state.currencies.indexOfFirst { it.code == from.code } + onCodeChange(index) + }, + ) + } + } + item { + SwapBtn(modifier = Modifier.padding(top = 16.dp), onClick = onSwapClick) + Text( + modifier = Modifier.padding(top = 16.dp, start = 52.dp), + text = "To", + fontWeight = FontWeight.Medium, + color = ArkColor.TextSecondary, + ) + } + itemsIndexed(to, key = { _, amount -> amount.code }) { index, item -> + ReorderableItem(state = reorderableLazyColumnState, key = item.code) { + ToResult( + code = item.code, + amount = item.value, + scope = this, + haptic = haptic, + onCurrencyRemove = { + val index = state.currencies.indexOfFirst { it.code == item.code } + onCurrencyRemove(index) + }, + onCodeChange = { + val index = state.currencies.indexOfFirst { it.code == item.code } + onCodeChange(index) + }, + ) + } + } } @Composable private fun FromInput( - index: Int, code: CurrencyCode, amount: String, + haptic: ReorderHapticFeedback, + scope: ReorderableCollectionItemScope, onAmountChanged: (String) -> Unit, - onCodeChange: (Int) -> Unit, + onCodeChange: () -> Unit, ) { Row(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) { + Box( + modifier = + with(scope) { + Modifier + .width(24.dp) + .height(44.dp) + .draggableHandle( + onDragStarted = { + haptic.performHapticFeedback(ReorderHapticFeedbackType.START) + }, + onDragStopped = { + haptic.performHapticFeedback(ReorderHapticFeedbackType.END) + }, + ) + .clearAndSetSemantics { } + }, + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.ic_drag), + contentDescription = null, + tint = ArkColor.NeutralGray500, + ) + } + Row( modifier = Modifier .weight(1f) + .padding(start = 12.dp) .height(44.dp) .border( 1.dp, @@ -345,7 +429,7 @@ private fun FromInput( Modifier .fillMaxHeight() .clip(RoundedCornerShape(8.dp)) - .clickable { onCodeChange(index) }, + .clickable { onCodeChange() }, verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -390,24 +474,52 @@ private fun FromInput( @Composable private fun ToResult( - index: Int, code: CurrencyCode, amount: String, - onCurrencyRemove: (Int) -> Unit, - onCodeChange: (Int) -> Unit, + haptic: ReorderHapticFeedback, + scope: ReorderableCollectionItemScope, + onCurrencyRemove: () -> Unit, + onCodeChange: () -> Unit, ) { Row(modifier = Modifier.padding(top = 16.dp, start = 16.dp, end = 16.dp)) { + Box( + modifier = + with(scope) { + Modifier + .width(24.dp) + .height(44.dp) + .draggableHandle( + onDragStarted = { + haptic.performHapticFeedback(ReorderHapticFeedbackType.START) + }, + onDragStopped = { + haptic.performHapticFeedback(ReorderHapticFeedbackType.END) + }, + ) + .clearAndSetSemantics { } + }, + ) { + Icon( + modifier = Modifier.align(Alignment.Center), + painter = painterResource(R.drawable.ic_drag), + contentDescription = null, + tint = ArkColor.NeutralGray500, + ) + } + Row( modifier = Modifier .weight(1f) + .padding(start = 12.dp) .height(44.dp) .border( 1.dp, ArkColor.Border, RoundedCornerShape(8.dp), ) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(8.dp)) + .background(Color.White), verticalAlignment = Alignment.CenterVertically, ) { Row( @@ -415,7 +527,7 @@ private fun ToResult( Modifier .fillMaxHeight() .clip(RoundedCornerShape(8.dp)) - .clickable { onCodeChange(index) }, + .clickable { onCodeChange() }, verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -462,7 +574,8 @@ private fun ToResult( RoundedCornerShape(8.dp), ) .clip(RoundedCornerShape(8.dp)) - .clickable { onCurrencyRemove(index) }, + .background(Color.White) + .clickable { onCurrencyRemove() }, contentAlignment = Alignment.Center, ) { Icon( @@ -480,7 +593,12 @@ private fun SwapBtn( onClick: () -> Unit, ) { Row(modifier = modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { - AppHorDiv(modifier = Modifier.weight(1f).padding(start = 16.dp, end = 12.dp)) + AppHorDiv( + modifier = + Modifier + .weight(1f) + .padding(start = 16.dp, end = 12.dp), + ) OutlinedButton( modifier = Modifier.size(40.dp), shape = CircleShape, @@ -491,6 +609,11 @@ private fun SwapBtn( ) { Icon(painter = painterResource(R.drawable.ic_refresh), contentDescription = null) } - AppHorDiv(modifier = Modifier.weight(1f).padding(start = 12.dp, end = 16.dp)) + AppHorDiv( + modifier = + Modifier + .weight(1f) + .padding(start = 12.dp, end = 16.dp), + ) } } diff --git a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickViewModel.kt b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickViewModel.kt index 032bfb9e6..bae410aeb 100644 --- a/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickViewModel.kt +++ b/feature/quick/src/main/java/dev/arkbuilders/rate/feature/quick/presentation/add/AddQuickViewModel.kt @@ -164,6 +164,19 @@ class AddQuickViewModel( } } + fun onPairsSwap( + from: Int, + to: Int, + ) = intent { + val new = + state.currencies.toMutableList().apply { + add(to, removeAt(from)) + } + reduce { + state.copy(currencies = new) + } + } + fun onAddQuickPair() = intent { val from = state.currencies.first() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d936546e6..c8fc45036 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -37,6 +37,7 @@ workRuntimeKtx = "2.8.1" appcompat = "1.7.0" material = "1.12.0" gson = "2.11.0" +reorderable = "2.4.2" [libraries] ark-about = { module = "dev.arkbuilders.components:about", version.ref = "arkAbout" } @@ -85,6 +86,7 @@ timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +reorderable = { group = "sh.calvin.reorderable", name = "reorderable", version.ref = "reorderable" } [plugins] android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }