diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt similarity index 68% rename from app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt rename to app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt index 3223e10e94..1492031ea5 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt @@ -1,16 +1,16 @@ package to.bitkit.ui.screens.widgets.calculator +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.test.assertTextContains -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.hasSetTextAction -import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onRoot -import androidx.compose.ui.test.performTextClearance -import androidx.compose.ui.test.performTextInput -import androidx.compose.ui.test.printToString +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStore @@ -50,6 +50,7 @@ import to.bitkit.test.annotations.CalculatorWidget import to.bitkit.test.annotations.DeviceIntegration import to.bitkit.test.annotations.DeviceUiIntegration import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorNumberPadBar import to.bitkit.ui.theme.AppThemeSurface import java.util.Locale import javax.inject.Inject @@ -62,7 +63,7 @@ import kotlin.test.assertEquals @CalculatorWidget @DeviceIntegration @DeviceUiIntegration -class CalculatorCardIntegrationTest { +class CalculatorWidgetInputTest { @get:Rule val hiltRule = HiltAndroidRule(this) @@ -154,41 +155,27 @@ class CalculatorCardIntegrationTest { } @Test - fun btcInputUpdatesFiatValueAndPersistsWidgetState() { - setCalculatorCard() + fun btcInputViaNumberPadUpdatesFiatAndPersistsWidgetState() { + setCalculatorWidget() - replaceInput(BTC_INPUT_INDEX, "12340") + composeTestRule.onNodeWithTag(BTC_INPUT_TAG).performClick() + awaitNumberPad() + tapKeys("N1", "N2", "N3", "N4", "N0") - waitForValues( - btcValue = "12340", - fiatValue = "12.34", - ) - - assertInputText(BTC_INPUT_INDEX, "12 340") - assertInputText(FIAT_INPUT_INDEX, "12.34") - assertPersistedValues( - btcValue = "12340", - fiatValue = "12.34", - ) + waitForValues(btcValue = "12340", fiatValue = "12.34") + assertPersistedValues(btcValue = "12340", fiatValue = "12.34") } @Test - fun fiatInputUpdatesBtcValueAndPersistsWidgetState() { - setCalculatorCard() + fun fiatInputViaNumberPadUpdatesBtcAndPersistsWidgetState() { + setCalculatorWidget() - replaceInput(FIAT_INPUT_INDEX, "10.00") + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG).performClick() + awaitNumberPad() + tapKeys("N1", "N0", "NDecimal", "N0", "N0") - waitForValues( - btcValue = "10000", - fiatValue = "10.00", - ) - - assertInputText(BTC_INPUT_INDEX, "10 000") - assertInputText(FIAT_INPUT_INDEX, "10.00") - assertPersistedValues( - btcValue = "10000", - fiatValue = "10.00", - ) + waitForValues(btcValue = "10000", fiatValue = "10.00") + assertPersistedValues(btcValue = "10000", fiatValue = "10.00") } private fun createCalculatorViewModel(): CalculatorViewModel { @@ -206,80 +193,70 @@ class CalculatorCardIntegrationTest { )[CalculatorViewModel::class.java] } - private fun setCalculatorCard() { + private fun setCalculatorWidget() { composeTestRule.setContent { AppThemeSurface { - CalculatorCard( - calculatorViewModel = calculatorViewModel, - modifier = Modifier.fillMaxWidth() - ) + val state by calculatorViewModel.uiState.collectAsState() + Box(modifier = Modifier.fillMaxSize()) { + CalculatorCard( + btcPrimaryDisplayUnit = state.displayUnit, + btcValue = state.btcValue, + fiatSymbol = state.currencySymbol, + fiatName = state.selectedCurrency, + fiatValue = state.fiatValue, + activeInput = state.activeInput, + onSelectInput = calculatorViewModel::onInputSelected, + modifier = Modifier.fillMaxWidth() + ) + state.activeInput?.let { active -> + CalculatorNumberPadBar( + activeInput = active, + btcValue = state.btcValue, + fiatValue = state.fiatValue, + btcPrimaryDisplayUnit = state.displayUnit, + onBtcChange = calculatorViewModel::onBtcInputChanged, + onFiatChange = calculatorViewModel::onFiatInputChanged, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } + } } } composeTestRule.waitForIdle() + } + + private fun awaitNumberPad() { composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT + composeTestRule.onAllNodesWithTag(NUMBER_PAD_TAG).fetchSemanticsNodes().isNotEmpty() } } - private fun inputAt(index: Int) = composeTestRule.onAllNodes(hasSetTextAction())[index] - - private fun replaceInput( - index: Int, - text: String, - ) { - inputAt(index).performTextClearance() - inputAt(index).performTextInput(text) + private fun tapKeys(vararg keys: String) { + keys.forEach { key -> + composeTestRule.onNodeWithTag(key).performClick() + composeTestRule.waitForIdle() + } } private fun waitForValues( btcValue: String, fiatValue: String, ) { - runCatching { - composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - calculatorViewModel.uiState.value.btcValue == btcValue && - calculatorViewModel.uiState.value.fiatValue == fiatValue - } - }.onFailure { - throw AssertionError( - buildString { - append("Expected calculatorValues btcValue='$btcValue', fiatValue='$fiatValue', ") - append("but was '${calculatorViewModel.uiState.value}'. Persisted values were ") - append("'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'. Semantics tree:\n") - append(composeTestRule.onRoot(useUnmergedTree = true).printToString()) - }, - it, - ) + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + calculatorViewModel.uiState.value.btcValue == btcValue && + calculatorViewModel.uiState.value.fiatValue == fiatValue } - val expectedValues = CalculatorValues( btcValue = btcValue, fiatValue = fiatValue, satsValue = btcValue.toLong(), displayUnit = BitcoinDisplayUnit.MODERN, ) - runCatching { - composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - widgetsRepo.widgetsDataFlow.value.calculatorValues == expectedValues - } - }.onFailure { - throw AssertionError( - "Expected persisted values '$expectedValues', but was " + - "'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'", - it, - ) + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + widgetsRepo.widgetsDataFlow.value.calculatorValues == expectedValues } } - private fun assertInputText( - inputIndex: Int, - text: String, - ) { - inputAt(inputIndex).assertTextContains(text, substring = true) - composeTestRule.onNode(hasText(text, substring = true), useUnmergedTree = true) - .assertIsDisplayed() - } - private fun assertPersistedValues( btcValue: String, fiatValue: String, @@ -296,9 +273,9 @@ class CalculatorCardIntegrationTest { } companion object { - private const val BTC_INPUT_INDEX = 0 - private const val FIAT_INPUT_INDEX = 1 - private const val INPUT_COUNT = 2 + private const val BTC_INPUT_TAG = "CalculatorBtcInput" + private const val FIAT_INPUT_TAG = "CalculatorFiatInput" + private const val NUMBER_PAD_TAG = "CalculatorNumberPad" private const val TIMEOUT_MS = 5_000L private const val TEST_CREATED_AT = 0L private const val TEST_USD_RATE = "100000" @@ -330,6 +307,7 @@ class CalculatorCardIntegrationTest { @Provides @Named("enablePolling") + @Suppress("FunctionOnlyReturningConstant") fun provideEnablePolling(): Boolean = false } } diff --git a/app/src/main/java/to/bitkit/data/WidgetsStore.kt b/app/src/main/java/to/bitkit/data/WidgetsStore.kt index 7ea9a6febf..d1b3b796bb 100644 --- a/app/src/main/java/to/bitkit/data/WidgetsStore.kt +++ b/app/src/main/java/to/bitkit/data/WidgetsStore.kt @@ -13,6 +13,7 @@ import to.bitkit.data.dto.BlockDTO import to.bitkit.data.dto.WeatherDTO import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.serializers.WidgetsSerializer +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.WidgetsBackupV1 @@ -123,15 +124,28 @@ class WidgetsStore @Inject constructor( Logger.info("Deleted all widgets data.") } - suspend fun addWidget(type: WidgetType) { - if (store.data.first().widgets.map { it.type }.contains(type)) return + suspend fun addWidget(type: WidgetType, size: WidgetSize = WidgetSize.default(type)) { + store.updateData { data -> + val existing = data.widgets.firstOrNull { it.type == type } + if (existing != null) { + data.copy( + widgets = data.widgets + .map { if (it.type == type) it.copy(size = size) else it } + .sortedBy { it.position } + ) + } else { + val nextPosition = (data.widgets.maxOfOrNull { it.position } ?: -1) + 1 + data.copy( + widgets = (data.widgets + WidgetWithPosition(type = type, position = nextPosition, size = size)) + .sortedBy { it.position } + ) + } + } + } + suspend fun updateWidgetSize(type: WidgetType, size: WidgetSize) { store.updateData { data -> - val nextPosition = (data.widgets.maxOfOrNull { it.position } ?: -1) + 1 - data.copy( - widgets = (data.widgets + WidgetWithPosition(type = type, position = nextPosition)) - .sortedBy { it.position } - ) + data.copy(widgets = data.widgets.map { if (it.type == type) it.copy(size = size) else it }) } } @@ -161,9 +175,13 @@ class WidgetsStore @Inject constructor( @Serializable data class WidgetsData( val widgets: List = listOf( - WidgetWithPosition(type = WidgetType.SUGGESTIONS, position = 0), - WidgetWithPosition(type = WidgetType.PRICE, position = 1), - WidgetWithPosition(type = WidgetType.BLOCK, position = 2), + WidgetWithPosition(type = WidgetType.SUGGESTIONS, position = 0, size = WidgetSize.WIDE), + WidgetWithPosition(type = WidgetType.PRICE, position = 1, size = WidgetSize.WIDE), + WidgetWithPosition(type = WidgetType.BLOCK, position = 2, size = WidgetSize.SMALL), + WidgetWithPosition(type = WidgetType.FACTS, position = 3, size = WidgetSize.SMALL), + WidgetWithPosition(type = WidgetType.WEATHER, position = 4, size = WidgetSize.SMALL), + WidgetWithPosition(type = WidgetType.CALCULATOR, position = 5, size = WidgetSize.SMALL), + WidgetWithPosition(type = WidgetType.NEWS, position = 6, size = WidgetSize.WIDE), ), val headlinePreferences: HeadlinePreferences = HeadlinePreferences(), val blocksPreferences: BlocksPreferences = BlocksPreferences(), diff --git a/app/src/main/java/to/bitkit/models/WidgetSize.kt b/app/src/main/java/to/bitkit/models/WidgetSize.kt new file mode 100644 index 0000000000..e3f7293924 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/WidgetSize.kt @@ -0,0 +1,24 @@ +package to.bitkit.models + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +enum class WidgetSize { + @SerialName("small") + SMALL, + + @SerialName("wide") + WIDE; + + companion object { + fun default(type: WidgetType): WidgetSize = when (type) { + WidgetType.PRICE, + WidgetType.NEWS, + WidgetType.SUGGESTIONS, + -> WIDE + + else -> SMALL + } + } +} diff --git a/app/src/main/java/to/bitkit/models/WidgetWithPosition.kt b/app/src/main/java/to/bitkit/models/WidgetWithPosition.kt index bb433bf177..cd6a0dc765 100644 --- a/app/src/main/java/to/bitkit/models/WidgetWithPosition.kt +++ b/app/src/main/java/to/bitkit/models/WidgetWithPosition.kt @@ -8,4 +8,8 @@ import kotlinx.serialization.Serializable data class WidgetWithPosition( val type: WidgetType, val position: Int = 0, + val size: WidgetSize = WidgetSize.WIDE, ) + +fun WidgetWithPosition.effectiveSize(): WidgetSize = + if (type == WidgetType.SUGGESTIONS) WidgetSize.WIDE else size diff --git a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt index da45b9a6f1..121cad7876 100644 --- a/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt @@ -33,6 +33,7 @@ import to.bitkit.data.widgets.PriceService import to.bitkit.data.widgets.WeatherService import to.bitkit.data.widgets.WidgetService import to.bitkit.di.BgDispatcher +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition import to.bitkit.models.widget.BlocksPreferences @@ -173,7 +174,11 @@ class WidgetsRepo @Inject constructor( Logger.verbose("Stopped refresh coroutine for $widgetType", context = TAG) } - suspend fun addWidget(type: WidgetType) = withContext(bgDispatcher) { widgetsStore.addWidget(type) } + suspend fun addWidget(type: WidgetType, size: WidgetSize = WidgetSize.default(type)) = + withContext(bgDispatcher) { widgetsStore.addWidget(type, size) } + + suspend fun updateWidgetSize(type: WidgetType, size: WidgetSize) = + withContext(bgDispatcher) { widgetsStore.updateWidgetSize(type, size) } suspend fun deleteWidget(type: WidgetType) = withContext(bgDispatcher) { widgetsStore.deleteWidget(type) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt index c99e8ed98d..1af5a07764 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -1,9 +1,9 @@ package to.bitkit.ui.screens.wallets import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.AnimationConstants import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.animateDpAsState import androidx.compose.animation.core.exponentialDecay import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween @@ -40,6 +40,7 @@ import androidx.compose.material3.DrawerValue import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.VerticalDivider @@ -65,6 +66,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.pointer.PointerEventPass import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput @@ -73,6 +75,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.platform.testTag @@ -110,9 +113,12 @@ import to.bitkit.env.Env import to.bitkit.models.ActivityBannerType import to.bitkit.models.BalanceState import to.bitkit.models.BannerItem +import to.bitkit.models.MoneyType import to.bitkit.models.Suggestion +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.effectiveSize import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel import to.bitkit.ui.LocalBalances @@ -145,19 +151,30 @@ import to.bitkit.ui.navigateToTransferIntro import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple import to.bitkit.ui.screens.wallets.activity.utils.previewActivityItems -import to.bitkit.ui.screens.widgets.DragAndDropWidget -import to.bitkit.ui.screens.widgets.DragDropColumn import to.bitkit.ui.screens.widgets.blocks.BlockCard +import to.bitkit.ui.screens.widgets.blocks.BlockCardSmall import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.calculator.CalculatorUiState +import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorNumberPadBar +import to.bitkit.ui.screens.widgets.components.EditableWidgetGrid +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens +import to.bitkit.ui.screens.widgets.components.WidgetFlowLayout import to.bitkit.ui.screens.widgets.facts.FactsCard +import to.bitkit.ui.screens.widgets.facts.FactsCardSmall import to.bitkit.ui.screens.widgets.headlines.HeadlineCard +import to.bitkit.ui.screens.widgets.headlines.HeadlineCardSmall import to.bitkit.ui.screens.widgets.price.PriceCard +import to.bitkit.ui.screens.widgets.price.PriceCardSmall import to.bitkit.ui.screens.widgets.weather.WeatherCard +import to.bitkit.ui.screens.widgets.weather.WeatherCardSmall import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute +import to.bitkit.ui.sheets.toWidgetsEditRoute import to.bitkit.ui.sheets.toWidgetsPreviewRoute import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -168,11 +185,14 @@ import to.bitkit.viewmodels.ActivityListViewModel import to.bitkit.viewmodels.AppViewModel import to.bitkit.viewmodels.SettingsViewModel import to.bitkit.viewmodels.WalletViewModel -import kotlin.math.roundToInt private const val SMALL_SCREEN_HEIGHT_DP = 800 private const val SMALL_SCREEN_SLOT_CAPACITY = 3 private const val LARGE_SCREEN_SLOT_CAPACITY = 4 +private val CALCULATOR_LIFT_SPEC = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow, +) private val BOTTOM_SPACER_HEIGHT = (TAB_BAR_HEIGHT + TAB_BAR_PADDING_BOTTOM + 36).dp @Suppress("CyclomaticComplexMethod") @@ -326,7 +346,8 @@ fun HomeScreen( onClickEditWidgetList = homeViewModel::onClickEditWidgetList, onClickEditWidget = { widgetType -> homeViewModel.disableEditMode() - appViewModel.showSheet(Sheet.Widgets(widgetType.toWidgetsPreviewRoute())) + val route = widgetType.toWidgetsEditRoute() ?: widgetType.toWidgetsPreviewRoute() + appViewModel.showSheet(Sheet.Widgets(route)) }, onClickDeleteWidget = { widgetType -> homeViewModel.displayAlertDeleteWidget(widgetType) @@ -514,7 +535,6 @@ private fun Content( 1 -> WidgetsPage( homeUiState = homeUiState, calculatorInputDismissKey = calculatorInputDismissKey, - isCalculatorInputActive = isCalculatorInputActive, onDismissCalculatorInput = ::dismissKeyboard, onCalculatorInputActiveChanged = onCalculatorInputActiveChange, onRemoveSuggestion = onRemoveSuggestion, @@ -681,12 +701,10 @@ private fun BalancesSection( } } -@Suppress("CyclomaticComplexMethod", "MagicNumber") @Composable private fun WidgetsPage( homeUiState: HomeUiState, calculatorInputDismissKey: Int, - isCalculatorInputActive: Boolean, onDismissCalculatorInput: () -> Unit, onCalculatorInputActiveChanged: (Boolean) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, @@ -696,60 +714,111 @@ private fun WidgetsPage( onClickDeleteWidget: (WidgetType) -> Unit, onMoveWidget: (Int, Int) -> Unit, ) { + if (LocalInspectionMode.current) { + WidgetsPageContent( + homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + calcState = CalculatorUiState(), + onDismissCalculatorInput = onDismissCalculatorInput, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, + onInputDismissed = {}, + onInputSelected = {}, + onBtcInputChanged = {}, + onFiatInputChanged = {}, + onRemoveSuggestion = onRemoveSuggestion, + onClickSuggestion = onClickSuggestion, + onClickAddWidget = onClickAddWidget, + onClickEditWidget = onClickEditWidget, + onClickDeleteWidget = onClickDeleteWidget, + onMoveWidget = onMoveWidget, + ) + return + } + + val calculatorViewModel: CalculatorViewModel = hiltViewModel() + val calcState by calculatorViewModel.uiState.collectAsStateWithLifecycle() + WidgetsPageContent( + homeUiState = homeUiState, + calculatorInputDismissKey = calculatorInputDismissKey, + calcState = calcState, + onDismissCalculatorInput = onDismissCalculatorInput, + onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, + onInputDismissed = calculatorViewModel::onInputDismissed, + onInputSelected = calculatorViewModel::onInputSelected, + onBtcInputChanged = calculatorViewModel::onBtcInputChanged, + onFiatInputChanged = calculatorViewModel::onFiatInputChanged, + onRemoveSuggestion = onRemoveSuggestion, + onClickSuggestion = onClickSuggestion, + onClickAddWidget = onClickAddWidget, + onClickEditWidget = onClickEditWidget, + onClickDeleteWidget = onClickDeleteWidget, + onMoveWidget = onMoveWidget, + ) +} + +@Suppress("CyclomaticComplexMethod", "MagicNumber", "LongMethod") +@Composable +private fun WidgetsPageContent( + homeUiState: HomeUiState, + calculatorInputDismissKey: Int, + calcState: CalculatorUiState, + onDismissCalculatorInput: () -> Unit, + onCalculatorInputActiveChanged: (Boolean) -> Unit, + onInputDismissed: () -> Unit, + onInputSelected: (MoneyType) -> Unit, + onBtcInputChanged: (String) -> Unit, + onFiatInputChanged: (String) -> Unit, + onRemoveSuggestion: (Suggestion) -> Unit, + onClickSuggestion: (Suggestion) -> Unit, + onClickAddWidget: () -> Unit, + onClickEditWidget: (WidgetType) -> Unit, + onClickDeleteWidget: (WidgetType) -> Unit, + onMoveWidget: (Int, Int) -> Unit, +) { + val isCalcActive = calcState.activeInput != null + val imeBottomPadding = WindowInsets.ime.asPaddingValues().calculateBottomPadding() val widgetsScrollState = rememberScrollState() val density = LocalDensity.current var pageBounds by remember { mutableStateOf(null) } var calculatorBounds by remember { mutableStateOf(null) } var numberPadBounds by remember { mutableStateOf(null) } - var firstCalculatorTopPaddingTarget by remember { mutableStateOf(0.dp) } - val firstCalculatorTopPadding by animateDpAsState( - targetValue = firstCalculatorTopPaddingTarget, - animationSpec = tween(durationMillis = AnimationConstants.DefaultDurationMillis), - label = "firstCalculatorTopPadding", - ) - val latestPageBounds by rememberUpdatedState(pageBounds) + val latestCalculatorBounds by rememberUpdatedState(calculatorBounds) val latestNumberPadBounds by rememberUpdatedState(numberPadBounds) - val isCalculatorFirst = homeUiState.widgetsWithPosition.firstOrNull()?.type == WidgetType.CALCULATOR + val revealMarginPx = with(density) { 16.dp.toPx() } + // Lifts the page so the focused calculator clears the number pad, snapping back when it closes. + val focusedOffsetY = remember { Animatable(0f) } + // Keep the hoisted state in sync so the pager and page-change handling react to calculator input. + LaunchedEffect(isCalcActive) { onCalculatorInputActiveChanged(isCalcActive) } + + // Honor external dismiss requests (page change, profile/drawer taps, etc.). + LaunchedEffect(calculatorInputDismissKey) { + if (calculatorInputDismissKey != 0) onInputDismissed() + } + + // Drop the calculator input when editing or when the widget is no longer present. LaunchedEffect(homeUiState.widgetsWithPosition, homeUiState.isEditingWidgets) { val hasCalculator = homeUiState.widgetsWithPosition.any { it.type == WidgetType.CALCULATOR } if (homeUiState.isEditingWidgets || !hasCalculator) { + onInputDismissed() calculatorBounds = null numberPadBounds = null - firstCalculatorTopPaddingTarget = 0.dp } } - LaunchedEffect(isCalculatorInputActive, numberPadBounds != null, isCalculatorFirst) { - if (!isCalculatorInputActive || numberPadBounds == null) { - firstCalculatorTopPaddingTarget = 0.dp + // Lift the page so the focused calculator card sits above the number pad bar (any position), + // and snap it back when the input closes — matching iOS's content offset. + LaunchedEffect(isCalcActive, numberPadBounds != null) { + if (!isCalcActive || numberPadBounds == null) { + focusedOffsetY.animateTo(0f, CALCULATOR_LIFT_SPEC) return@LaunchedEffect } - withFrameNanos { - // Wait for the focused calculator and number pad bounds to settle before measuring. - } - - val page = latestPageBounds ?: return@LaunchedEffect + withFrameNanos { } + val calculator = latestCalculatorBounds ?: return@LaunchedEffect val numberPad = latestNumberPadBounds ?: return@LaunchedEffect - val firstCalculatorBottomGap = page.bottom - numberPad.bottom - - if (isCalculatorFirst && widgetsScrollState.value == 0 && firstCalculatorBottomGap > 0f) { - firstCalculatorTopPaddingTarget = with(density) { firstCalculatorBottomGap.toDp() } - return@LaunchedEffect - } - - firstCalculatorTopPaddingTarget = 0.dp - val targetScroll = calculatorRevealScrollTarget( - currentScroll = widgetsScrollState.value, - maxScroll = widgetsScrollState.maxValue, - pageBounds = page, - numberPadBounds = numberPad, - ) - - if (targetScroll != widgetsScrollState.value) { - widgetsScrollState.animateScrollTo(targetScroll) - } + val overlap = calculator.bottom - (numberPad.top - revealMarginPx) + focusedOffsetY.animateTo(if (overlap > 0f) -overlap else 0f, CALCULATOR_LIFT_SPEC) } Box( @@ -757,9 +826,10 @@ private fun WidgetsPage( .fillMaxSize() .onGloballyPositioned { pageBounds = it.boundsInRoot() } .dismissCalculatorInputOnOutsideTap( - isCalculatorInputActive = isCalculatorInputActive, + isCalculatorInputActive = isCalcActive, pageBounds = pageBounds, calculatorBounds = calculatorBounds, + numberPadBounds = numberPadBounds, onDismiss = onDismissCalculatorInput, ) ) { @@ -767,9 +837,10 @@ private fun WidgetsPage( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxSize() + .graphicsLayer { translationY = focusedOffsetY.value } .verticalScroll( state = widgetsScrollState, - enabled = !isCalculatorInputActive, + enabled = !isCalcActive, ) ) { StatusBarSpacer() @@ -777,45 +848,52 @@ private fun WidgetsPage( VerticalSpacer(16.dp) if (homeUiState.isEditingWidgets) { - DragDropColumn( + EditableWidgetGrid( items = homeUiState.widgetsWithPosition, onMove = onMoveWidget, - modifier = Modifier.fillMaxWidth() - ) { widgetWithPosition, isDragging, dragModifier -> - DragAndDropWidget( - iconRes = widgetWithPosition.type.iconRes, - title = stringResource(widgetWithPosition.type.title), - onClickSettings = { onClickEditWidget(widgetWithPosition.type) }, - onClickDelete = { onClickDeleteWidget(widgetWithPosition.type) }, - dragModifier = dragModifier, - modifier = Modifier - .fillMaxWidth() + onDelete = onClickDeleteWidget, + onSettings = onClickEditWidget, + modifier = Modifier.fillMaxWidth(), + ) { widget -> + WidgetCardContent( + widget = widget, + homeUiState = homeUiState, + calcState = calcState, + onSelectCalcInput = null, + onCalculatorBoundsChanged = {}, + onRemoveSuggestion = onRemoveSuggestion, + onClickSuggestion = onClickSuggestion, ) } } else { - VerticalSpacer(firstCalculatorTopPadding) - - Widgets( - homeUiState = homeUiState, - calculatorInputDismissKey = calculatorInputDismissKey, - showOnlyCalculator = isCalculatorInputActive && isCalculatorFirst, - onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, - onCalculatorBoundsChanged = { calculatorBounds = it }, - onNumberPadBoundsChanged = { numberPadBounds = it }, - onRemoveSuggestion = onRemoveSuggestion, - onClickSuggestion = onClickSuggestion, + val visibleWidgets = homeUiState.widgetsWithPosition + .filter { hasWidgetContent(it.type, homeUiState) } + WidgetFlowLayout( + isWide = visibleWidgets.map { it.effectiveSize() == WidgetSize.WIDE }.toImmutableList(), modifier = Modifier.fillMaxWidth() - ) + ) { + visibleWidgets.forEach { widget -> + WidgetCardContent( + widget = widget, + homeUiState = homeUiState, + calcState = calcState, + onSelectCalcInput = onInputSelected, + onCalculatorBoundsChanged = { calculatorBounds = it }, + onRemoveSuggestion = onRemoveSuggestion, + onClickSuggestion = onClickSuggestion, + ) + } + } } - val footerAlpha = if (isCalculatorInputActive) 0f else 1f + val footerAlpha = if (isCalcActive) 0f else 1f VerticalSpacer(16.dp) TertiaryButton( text = stringResource(R.string.widgets__add), onClick = onClickAddWidget, - enabled = !isCalculatorInputActive, + enabled = !isCalcActive, icon = { Icon( painter = painterResource(R.drawable.ic_plus), @@ -830,25 +908,30 @@ private fun WidgetsPage( VerticalSpacer(150.dp + imeBottomPadding) } - } -} -private fun calculatorRevealScrollTarget( - currentScroll: Int, - maxScroll: Int, - pageBounds: Rect, - numberPadBounds: Rect, -): Int { - val delta = numberPadBounds.bottom - pageBounds.bottom - return (currentScroll + delta.roundToInt()).coerceIn(0, maxScroll) + calcState.activeInput?.let { activeInput -> + CalculatorNumberPadBar( + activeInput = activeInput, + btcValue = calcState.btcValue, + fiatValue = calcState.fiatValue, + btcPrimaryDisplayUnit = calcState.displayUnit, + onBtcChange = onBtcInputChanged, + onFiatChange = onFiatInputChanged, + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { numberPadBounds = it.boundsInRoot() } + ) + } + } } private fun Modifier.dismissCalculatorInputOnOutsideTap( isCalculatorInputActive: Boolean, pageBounds: Rect?, calculatorBounds: Rect?, + numberPadBounds: Rect?, onDismiss: () -> Unit, -): Modifier = pointerInput(isCalculatorInputActive, pageBounds, calculatorBounds) { +): Modifier = pointerInput(isCalculatorInputActive, pageBounds, calculatorBounds, numberPadBounds) { if (!isCalculatorInputActive) return@pointerInput awaitEachGesture { @@ -867,7 +950,8 @@ private fun Modifier.dismissCalculatorInputOnOutsideTap( } if (pointer.changedToUpIgnoreConsumed()) { isPointerUp = true - if (isTap && !calculator.contains(tapPositionInRoot)) { + val onNumberPad = numberPadBounds?.contains(tapPositionInRoot) == true + if (isTap && !calculator.contains(tapPositionInRoot) && !onNumberPad) { pointer.consume() onDismiss() } @@ -943,117 +1027,181 @@ private fun WidgetsOnboardingHint(modifier: Modifier = Modifier) { } } -@Suppress("CyclomaticComplexMethod") +private fun hasWidgetContent(type: WidgetType, state: HomeUiState): Boolean = when (type) { + WidgetType.BLOCK -> state.currentBlock != null + WidgetType.NEWS -> state.currentArticle != null + WidgetType.PRICE -> state.currentPrice != null + WidgetType.WEATHER -> state.currentWeather != null + WidgetType.FACTS -> state.currentFact != null + WidgetType.CALCULATOR -> true + WidgetType.SUGGESTIONS -> state.suggestions.isNotEmpty() +} + +@Suppress("CyclomaticComplexMethod", "LongMethod") @Composable -private fun Widgets( +private fun WidgetCardContent( + widget: WidgetWithPosition, homeUiState: HomeUiState, - calculatorInputDismissKey: Int, - showOnlyCalculator: Boolean, - onCalculatorInputActiveChanged: (Boolean) -> Unit, + calcState: CalculatorUiState, + onSelectCalcInput: ((MoneyType) -> Unit)?, onCalculatorBoundsChanged: (Rect) -> Unit, - onNumberPadBoundsChanged: (Rect?) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, onClickSuggestion: (Suggestion) -> Unit, - modifier: Modifier = Modifier, ) { - val widgets = if (showOnlyCalculator) { - homeUiState.widgetsWithPosition.take(1) - } else { - homeUiState.widgetsWithPosition - } + val small = widget.effectiveSize() == WidgetSize.SMALL + when (widget.type) { + WidgetType.BLOCK -> { + val block = homeUiState.currentBlock + when { + block == null -> WidgetEditPlaceholder(small = small) + small -> BlockCardSmall( + preferences = homeUiState.blocksPreferences, + block = block, + modifier = Modifier.testTag("BlocksWidget") + ) - Column( - verticalArrangement = Arrangement.spacedBy(16.dp), - modifier = modifier.fillMaxWidth() - ) { - widgets.forEach { widgetsWithPosition -> - when (widgetsWithPosition.type) { - WidgetType.BLOCK -> { - homeUiState.currentBlock?.let { block -> - BlockCard( - preferences = homeUiState.blocksPreferences, - block = block, - modifier = Modifier - .fillMaxWidth() - .testTag("BlocksWidget") - ) - } - } + else -> BlockCard( + preferences = homeUiState.blocksPreferences, + block = block, + modifier = Modifier.fillMaxWidth().testTag("BlocksWidget") + ) + } + } - WidgetType.CALCULATOR -> { - CalculatorCard( - dismissNumberPadKey = calculatorInputDismissKey, - onInputActiveChange = onCalculatorInputActiveChanged, - onNumberPadBoundsChanged = onNumberPadBoundsChanged, - modifier = Modifier - .fillMaxWidth() - .onGloballyPositioned { - onCalculatorBoundsChanged(it.boundsInRoot()) - } - ) - } + WidgetType.NEWS -> { + val article = homeUiState.currentArticle + when { + article == null -> WidgetEditPlaceholder(small = small) + small -> HeadlineCardSmall( + showTime = homeUiState.headlinePreferences.showTime, + time = article.timeAgo, + headline = article.title, + link = article.link, + modifier = Modifier.testTag("NewsWidget") + ) - WidgetType.FACTS -> { - homeUiState.currentFact?.run { - FactsCard( - headline = homeUiState.currentFact, - modifier = Modifier.fillMaxWidth() - ) - } - } + else -> HeadlineCard( + showTime = homeUiState.headlinePreferences.showTime, + showSource = homeUiState.headlinePreferences.showSource, + headline = article.title, + time = article.timeAgo, + source = article.publisher, + link = article.link, + modifier = Modifier.fillMaxWidth().testTag("NewsWidget") + ) + } + } - WidgetType.NEWS -> { - homeUiState.currentArticle?.run { - HeadlineCard( - showTime = homeUiState.headlinePreferences.showTime, - showSource = homeUiState.headlinePreferences.showSource, - headline = title, - time = timeAgo, - source = publisher, - link = link, - modifier = Modifier - .fillMaxWidth() - .testTag("NewsWidget") - ) - } - } + WidgetType.PRICE -> { + val price = homeUiState.currentPrice + when { + price == null -> WidgetEditPlaceholder(small = small) + small -> PriceCardSmall( + pricePreferences = homeUiState.pricePreferences, + priceDTO = price, + modifier = Modifier.testTag("PriceWidget") + ) - WidgetType.PRICE -> { - homeUiState.currentPrice?.run { - PriceCard( - pricePreferences = homeUiState.pricePreferences, - priceDTO = homeUiState.currentPrice, - modifier = Modifier - .fillMaxWidth() - .testTag("PriceWidget") - ) - } - } + else -> PriceCard( + pricePreferences = homeUiState.pricePreferences, + priceDTO = price, + modifier = Modifier.fillMaxWidth().testTag("PriceWidget") + ) + } + } - WidgetType.WEATHER -> { - homeUiState.currentWeather?.run { - WeatherCard( - weatherModel = this, - preferences = homeUiState.weatherPreferences, - modifier = Modifier.fillMaxWidth() - ) - } - } + WidgetType.WEATHER -> { + val weather = homeUiState.currentWeather + when { + weather == null -> WidgetEditPlaceholder(small = small) + small -> WeatherCardSmall( + weatherModel = weather, + preferences = homeUiState.weatherPreferences, + modifier = Modifier.testTag("WeatherWidget") + ) - WidgetType.SUGGESTIONS -> { - if (homeUiState.suggestions.isNotEmpty()) { - SuggestionsSection( - suggestions = homeUiState.suggestions, - onRemoveSuggestion = onRemoveSuggestion, - onClickSuggestion = onClickSuggestion, - ) - } - } + else -> WeatherCard( + weatherModel = weather, + preferences = homeUiState.weatherPreferences, + modifier = Modifier.fillMaxWidth().testTag("WeatherWidget") + ) + } + } + + WidgetType.FACTS -> { + val fact = homeUiState.currentFact + when { + fact == null -> WidgetEditPlaceholder(small = small) + small -> FactsCardSmall( + headline = fact, + modifier = Modifier.testTag("FactsWidget") + ) + + else -> FactsCard( + headline = fact, + modifier = Modifier.fillMaxWidth().testTag("FactsWidget") + ) + } + } + + WidgetType.CALCULATOR -> { + val calcModifier = Modifier + .fillMaxWidth() + .onGloballyPositioned { onCalculatorBoundsChanged(it.boundsInRoot()) } + .testTag("CalculatorWidget") + if (small) { + CalculatorCardSmall( + btcPrimaryDisplayUnit = calcState.displayUnit, + btcValue = calcState.btcValue, + fiatSymbol = calcState.currencySymbol, + fiatValue = calcState.fiatValue, + activeInput = calcState.activeInput, + onSelectInput = onSelectCalcInput, + modifier = calcModifier + ) + } else { + CalculatorCard( + btcPrimaryDisplayUnit = calcState.displayUnit, + btcValue = calcState.btcValue, + fiatSymbol = calcState.currencySymbol, + fiatName = calcState.selectedCurrency, + fiatValue = calcState.fiatValue, + activeInput = calcState.activeInput, + onSelectInput = onSelectCalcInput ?: {}, + modifier = calcModifier + ) + } + } + + WidgetType.SUGGESTIONS -> { + if (homeUiState.suggestions.isEmpty()) { + WidgetEditPlaceholder(small = false) + } else { + SuggestionsSection( + suggestions = homeUiState.suggestions, + onRemoveSuggestion = onRemoveSuggestion, + onClickSuggestion = onClickSuggestion, + modifier = Modifier.fillMaxWidth() + ) } } } } +@Composable +private fun WidgetEditPlaceholder( + small: Boolean, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .then(if (small) Modifier else Modifier.height(WidgetCardDimens.COMPACT_CARD_SIZE.height)) + .clip(MaterialTheme.shapes.medium) + .background(Colors.Gray6) + ) +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun TopBar( diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt index befd009c8b..267e1de3c2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt @@ -42,6 +42,7 @@ import to.bitkit.models.WidgetType import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel import to.bitkit.models.widget.BlocksPreferences +import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.PricePreferences import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences @@ -78,6 +79,7 @@ fun AddWidgetsSheetContent( block: BlockModel? = null, fact: String? = null, price: PriceDTO? = PreviewPrice, + calculatorValues: CalculatorValues = CalculatorValues(), ) { Column( modifier = modifier @@ -105,6 +107,7 @@ fun AddWidgetsSheetContent( block = block, fact = fact, price = price, + calculatorValues = calculatorValues, modifier = Modifier.fillMaxWidth() ) } @@ -127,6 +130,7 @@ private fun WidgetsGalleryList( block: BlockModel? = null, fact: String? = null, price: PriceDTO? = PreviewPrice, + calculatorValues: CalculatorValues = CalculatorValues(), ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), @@ -149,7 +153,6 @@ private fun WidgetsGalleryList( PriceCardSmall( pricePreferences = PreviewPricePreferences, priceDTO = price, - backgroundColor = Colors.Gray6, modifier = Modifier.smallPreviewCard() ) } @@ -184,7 +187,6 @@ private fun WidgetsGalleryList( source = previewArticle.publisher, link = previewArticle.link, enabled = showWidgets, - backgroundColor = Colors.Gray6, modifier = Modifier .fillMaxWidth() .testTag("headline_card_wide") @@ -201,7 +203,6 @@ private fun WidgetsGalleryList( BlockCard( preferences = PreviewBlocksPreferences, block = block ?: PreviewBlock, - backgroundColor = Colors.Gray6, ) } @@ -217,7 +218,6 @@ private fun WidgetsGalleryList( ) { FactsCardSmall( headline = fact ?: PREVIEW_FACT, - backgroundColor = Colors.Gray6, modifier = Modifier.smallPreviewCard() ) } @@ -232,10 +232,10 @@ private fun WidgetsGalleryList( modifier = Modifier.weight(1f) ) { CalculatorCardSmall( - btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, - btcValue = PREVIEW_CALCULATOR_BTC_VALUE, + btcPrimaryDisplayUnit = calculatorValues.displayUnit ?: BitcoinDisplayUnit.MODERN, + btcValue = calculatorValues.btcValue, fiatSymbol = fiatSymbol, - fiatValue = PREVIEW_CALCULATOR_FIAT_VALUE, + fiatValue = calculatorValues.fiatValue, modifier = Modifier.smallPreviewCard() ) } @@ -488,8 +488,6 @@ private val PreviewBlocksPreferences = BlocksPreferences( showFees = false, ) -private const val PREVIEW_CALCULATOR_BTC_VALUE = "10000" -private const val PREVIEW_CALCULATOR_FIAT_VALUE = "4.55" private const val PREVIEW_FACT = "Bitcoin doesn’t need your personal information" private val PreviewArticle = ArticleModel( timeAgo = "21 min ago", diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/DragAndDropWidget.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/DragAndDropWidget.kt deleted file mode 100644 index 92305e7ee0..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/DragAndDropWidget.kt +++ /dev/null @@ -1,115 +0,0 @@ -package to.bitkit.ui.screens.widgets - -import androidx.annotation.DrawableRes -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import to.bitkit.R -import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.ui.theme.Colors - -@Composable -fun DragAndDropWidget( - @DrawableRes iconRes: Int, - title: String, - onClickDelete: () -> Unit, - onClickSettings: () -> Unit, - modifier: Modifier = Modifier, - dragModifier: Modifier = Modifier, -) { - Box( - modifier = modifier - .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.Gray6) - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Icon( - painter = painterResource(iconRes), - contentDescription = null, - modifier = Modifier - .size(32.dp) - .clip(RoundedCornerShape(6.dp)) - .testTag("${title}_drag_and_drop_icon"), - tint = Color.Unspecified - ) - - BodyMSB( - text = title, - modifier = Modifier - .weight(1f) - .padding(start = 16.dp) - .testTag("${title}_drag_and_drop_title") - ) - - IconButton( - onClick = onClickDelete, - modifier = Modifier.testTag("${title}_WidgetActionDelete") - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_trash), - contentDescription = stringResource(R.string.common__delete) - ) - } - - IconButton( - onClick = onClickSettings, - modifier = Modifier.testTag("${title}_WidgetActionEdit") - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_settings), - contentDescription = stringResource(R.string.common__edit) - ) - } - - IconButton( - onClick = {}, - modifier = dragModifier.testTag("${title}_WidgetActionDrag") - ) { - Icon( - modifier = Modifier.size(24.dp), - painter = painterResource(R.drawable.ic_list), - contentDescription = null - ) - } - } - } -} - -@Preview(showBackground = true) -@Composable -private fun Preview() { - AppThemeSurface { - DragAndDropWidget( - modifier = Modifier.padding(16.dp), - iconRes = R.drawable.widget_cube, - title = stringResource(R.string.widgets__blocks__name), - onClickDelete = {}, - onClickSettings = {} - ) - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/DragDropColumn.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/DragDropColumn.kt deleted file mode 100644 index a95a4fa2fe..0000000000 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/DragDropColumn.kt +++ /dev/null @@ -1,92 +0,0 @@ -package to.bitkit.ui.screens.widgets - -import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.runtime.Composable -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.ui.Modifier -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.unit.dp -import androidx.compose.ui.zIndex -import kotlinx.collections.immutable.ImmutableList -import to.bitkit.models.WidgetWithPosition -import to.bitkit.ui.components.VerticalSpacer - -private const val DRAG_SCALE = 1.05f - -@Composable -fun DragDropColumn( - items: ImmutableList, - onMove: (Int, Int) -> Unit, - modifier: Modifier = Modifier, - itemContent: @Composable (WidgetWithPosition, Boolean, Modifier) -> Unit, -) { - var draggedItem by remember { mutableStateOf(null) } - var draggedItemOffset by remember { mutableFloatStateOf(0f) } - - Column( - modifier = modifier - ) { - items.forEachIndexed { index, item -> - val isDragging = draggedItem == index - - val dragModifier = Modifier.pointerInput(index) { - detectDragGesturesAfterLongPress( - onDragStart = { - draggedItem = index - }, - onDragEnd = { - draggedItem = null - draggedItemOffset = 0f - }, - onDragCancel = { - draggedItem = null - draggedItemOffset = 0f - }, - onDrag = { _, dragAmount -> - draggedItemOffset += dragAmount.y - - val itemHeight = 96.dp.toPx() // Item height + spacing (80dp + 16dp) - val draggedIndex = draggedItem ?: index - - // Calculate how many positions we've moved - val positionChange = (draggedItemOffset / itemHeight).toInt() - val newPosition = (draggedIndex + positionChange).coerceIn(0, items.size - 1) - - if (newPosition != draggedIndex) { - onMove(draggedIndex, newPosition) - draggedItem = newPosition - // Reset offset after moving to prevent accumulation - draggedItemOffset = draggedItemOffset % itemHeight - } - } - ) - } - - Box( - modifier = Modifier - .fillMaxWidth() - .graphicsLayer { - translationY = if (isDragging) draggedItemOffset else 0f - scaleX = if (isDragging) DRAG_SCALE else 1f - scaleY = if (isDragging) DRAG_SCALE else 1f - } - .zIndex(if (isDragging) 1f else 0f) - ) { - itemContent(item, isDragging, dragModifier) - } - - // Add spacing between items (except after the last item) - if (index < items.size - 1) { - VerticalSpacer(16.dp) - } - } - } -} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetSizeDraft.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetSizeDraft.kt new file mode 100644 index 0000000000..13f270129e --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetSizeDraft.kt @@ -0,0 +1,44 @@ +package to.bitkit.ui.screens.widgets + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import to.bitkit.data.WidgetsData +import to.bitkit.models.WidgetSize +import to.bitkit.models.WidgetType + +/** + * Tracks the widget size chosen in a preview sheet's size carousel. Before the user picks a size it + * reflects the persisted size (or the type default for a not-yet-saved widget); once the user swipes + * the carousel the draft takes over. [current] is read by the widget's save action. + */ +class WidgetSizeDraft( + scope: CoroutineScope, + private val type: WidgetType, + widgetsDataFlow: StateFlow, + subscriptionTimeoutMs: Long = SUBSCRIPTION_TIMEOUT, +) { + private val default = WidgetSize.default(type) + + private val savedSize: StateFlow = widgetsDataFlow + .map { data -> data.widgets.firstOrNull { it.type == type }?.size ?: default } + .stateIn(scope, SharingStarted.WhileSubscribed(subscriptionTimeoutMs), default) + + private val _draft = MutableStateFlow(null) + + val size: StateFlow = combine(_draft, savedSize) { draft, saved -> draft ?: saved } + .stateIn(scope, SharingStarted.WhileSubscribed(subscriptionTimeoutMs), default) + + val current: WidgetSize get() = size.value + + fun set(value: WidgetSize) = _draft.update { value } + + companion object { + private const val SUBSCRIPTION_TIMEOUT = 5000L + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsGalleryViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsGalleryViewModel.kt index 39b12f57c5..8768e78249 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsGalleryViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsGalleryViewModel.kt @@ -19,6 +19,7 @@ import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.WidgetType import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlockModel +import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.toArticleModel import to.bitkit.models.widget.toBlockModel import to.bitkit.repositories.CurrencyRepo @@ -73,6 +74,14 @@ class WidgetsGalleryViewModel @Inject constructor( initialValue = null, ) + val currentCalculatorValues: StateFlow = widgetsRepo.widgetsDataFlow + .map { it.calculatorValues } + .stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(SUBSCRIPTION_TIMEOUT), + initialValue = CalculatorValues(), + ) + fun refreshOnDisplay() { viewModelScope.launch { coroutineScope { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt index bb270941f4..e2b0119d62 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt @@ -8,6 +8,7 @@ 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.material3.Icon @@ -16,7 +17,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -36,7 +36,6 @@ import to.bitkit.ui.theme.Colors @Composable fun BlockCard( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -47,7 +46,7 @@ fun BlockCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -70,7 +69,6 @@ fun BlockCard( @Composable fun BlockCardSmall( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -80,9 +78,10 @@ fun BlockCardSmall( Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt index ef04ec4f1a..f38b19fd08 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.WidgetSize import to.bitkit.models.widget.BlockModel import to.bitkit.models.widget.BlocksPreferences import to.bitkit.ui.components.BodyM @@ -25,6 +27,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -42,6 +45,7 @@ fun BlocksPreviewScreen( val customBlocksPreferences by blocksViewModel.customPreferences.collectAsStateWithLifecycle() val currentBlock by blocksViewModel.currentBlock.collectAsStateWithLifecycle() val isBlocksWidgetEnabled by blocksViewModel.isBlocksWidgetEnabled.collectAsStateWithLifecycle() + val draftSize by blocksViewModel.draftSize.collectAsStateWithLifecycle() LaunchedEffect(Unit) { blocksViewModel.refreshOnDisplay() @@ -59,6 +63,8 @@ fun BlocksPreviewScreen( onClickSave = { blocksViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = blocksViewModel::setSize, modifier = modifier ) } @@ -73,6 +79,8 @@ private fun Content( blocksPreferences: BlocksPreferences, block: BlockModel?, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -122,7 +130,9 @@ private fun Content( BlockCardSmall( preferences = blocksPreferences, block = it, - modifier = Modifier.testTag("block_card_small") + modifier = Modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .testTag("block_card_small") ) }, wideContent = { @@ -134,6 +144,8 @@ private fun Content( .testTag("block_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("blocks_preview_carousel") @@ -148,7 +160,7 @@ private fun Content( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt index 8fc56aeae2..698d4f4f95 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt @@ -11,6 +11,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.widget.BlockModel import to.bitkit.models.widget.BlocksPreferences @@ -18,6 +19,7 @@ import to.bitkit.models.widget.BlocksWidgetField import to.bitkit.models.widget.toBlockModel import to.bitkit.models.widget.toggleField import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import javax.inject.Inject @HiltViewModel @@ -58,6 +60,11 @@ class BlocksViewModel @Inject constructor( private val _customPreferences = MutableStateFlow(BlocksPreferences()) val customPreferences: StateFlow = _customPreferences.asStateFlow() + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.BLOCK, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + init { initializeCustomPreferences() } @@ -77,7 +84,7 @@ class BlocksViewModel @Inject constructor( fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateBlocksPreferences(_customPreferences.value) - widgetsRepo.addWidget(WidgetType.BLOCK) + widgetsRepo.addWidget(WidgetType.BLOCK, sizeDraft.current) onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index bb01b60e7b..874af36393 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -17,14 +18,15 @@ import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.MoneyType +import to.bitkit.models.WidgetSize import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar -import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardEditor +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -40,21 +42,20 @@ fun CalculatorPreviewScreen( ) { val isCalculatorWidgetEnabled by viewModel.isCalculatorWidgetEnabled.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val draftSize by viewModel.draftSize.collectAsStateWithLifecycle() CalculatorPreviewContent( onBack = onBack, isCalculatorWidgetEnabled = isCalculatorWidgetEnabled, uiState = uiState, - onBtcChange = viewModel::onBtcInputChanged, - onFiatChange = viewModel::onFiatInputChanged, - onInputSelected = viewModel::onInputSelected, - onInputDismissed = viewModel::onInputDismissed, onClickDelete = { viewModel.removeWidget(onComplete = onClose) }, onClickSave = { viewModel.saveWidget(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = viewModel::setSize, modifier = modifier ) } @@ -67,10 +68,8 @@ fun CalculatorPreviewContent( isCalculatorWidgetEnabled: Boolean, modifier: Modifier = Modifier, uiState: CalculatorUiState = CalculatorUiState(), - onBtcChange: (String) -> Unit = {}, - onFiatChange: (String) -> Unit = {}, - onInputSelected: (MoneyType) -> Unit = {}, - onInputDismissed: () -> Unit = {}, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -111,25 +110,25 @@ fun CalculatorPreviewContent( btcValue = uiState.btcValue, fiatSymbol = uiState.currencySymbol, fiatValue = uiState.fiatValue, - modifier = Modifier.testTag("calculator_card_small") + modifier = Modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .testTag("calculator_card_small") ) }, wideContent = { - CalculatorCardEditor( + CalculatorCard( btcPrimaryDisplayUnit = uiState.displayUnit, btcValue = uiState.btcValue, - onBtcChange = onBtcChange, fiatSymbol = uiState.currencySymbol, fiatName = uiState.selectedCurrency, fiatValue = uiState.fiatValue, - onFiatChange = onFiatChange, - onInputSelected = onInputSelected, - onInputDismissed = onInputDismissed, modifier = Modifier .fillMaxWidth() .testTag("calculator_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("calculator_preview_carousel") @@ -143,7 +142,7 @@ fun CalculatorPreviewContent( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index 7bfe0752ec..2d1e8ebab3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -18,12 +18,14 @@ import kotlinx.coroutines.launch import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.FxRate import to.bitkit.models.MoneyType +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.widget.CalculatorValues import to.bitkit.models.widget.resolveCalculatorSatsValue import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.CurrencyState import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import java.math.BigDecimal import java.math.RoundingMode import java.text.DecimalFormatSymbols @@ -59,6 +61,11 @@ class CalculatorViewModel @Inject constructor( initialValue = false ) + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.CALCULATOR, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + init { observeCalculatorState() } @@ -72,17 +79,19 @@ class CalculatorViewModel @Inject constructor( fun saveWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { - widgetsRepo.addWidget(WidgetType.CALCULATOR) + widgetsRepo.addWidget(WidgetType.CALCULATOR, sizeDraft.current) onComplete() } } fun onInputSelected(input: MoneyType) { activeInput = input + _uiState.update { it.copy(activeInput = input) } } fun onInputDismissed() { activeInput = null + _uiState.update { it.copy(activeInput = null) } } fun onBtcInputChanged(rawValue: String) { @@ -382,6 +391,7 @@ data class CalculatorUiState( val displayUnit: BitcoinDisplayUnit = BitcoinDisplayUnit.MODERN, val currencySymbol: String = "$", val selectedCurrency: String = "USD", + val activeInput: MoneyType? = null, ) private data class CalculatorCurrencyKey( diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt index 77ed429de7..c9758a8e6f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorCard.kt @@ -1,63 +1,36 @@ package to.bitkit.ui.screens.widgets.calculator.components -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.EnterTransition -import androidx.compose.animation.core.MutableTransitionState -import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.MutableState -import androidx.compose.runtime.Stable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -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.geometry.Rect -import androidx.compose.ui.layout.boundsInRoot -import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import kotlinx.coroutines.delay import to.bitkit.R import to.bitkit.models.BITCOIN_SYMBOL import to.bitkit.models.BitcoinDisplayUnit import to.bitkit.models.CLASSIC_DECIMALS import to.bitkit.models.MoneyType import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.KEY_DELETE -import to.bitkit.ui.components.NavBarSpacer -import to.bitkit.ui.components.NumberPad -import to.bitkit.ui.components.NumberPadType import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.screens.widgets.calculator.CALCULATOR_FIAT_DECIMAL_PLACES -import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel import to.bitkit.ui.screens.widgets.calculator.applyNumberPadInput -import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator import to.bitkit.ui.screens.widgets.calculator.formatBitcoinPlaceholder import to.bitkit.ui.screens.widgets.calculator.formatBitcoinValue import to.bitkit.ui.screens.widgets.calculator.formatFiatPlaceholder @@ -66,283 +39,8 @@ import to.bitkit.ui.screens.widgets.calculator.isBtcValueInSatsRange import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import kotlin.time.Duration.Companion.milliseconds -@Composable -fun CalculatorCard( - modifier: Modifier = Modifier, - dismissNumberPadKey: Int = 0, - onInputActiveChange: (Boolean) -> Unit = {}, - onNumberPadBoundsChanged: (Rect?) -> Unit = {}, - calculatorViewModel: CalculatorViewModel = hiltViewModel(), -) { - val uiState by calculatorViewModel.uiState.collectAsStateWithLifecycle() - - CalculatorCardEditor( - modifier = modifier, - btcPrimaryDisplayUnit = uiState.displayUnit, - btcValue = uiState.btcValue, - onBtcChange = calculatorViewModel::onBtcInputChanged, - fiatSymbol = uiState.currencySymbol, - fiatName = uiState.selectedCurrency, - fiatValue = uiState.fiatValue, - onFiatChange = calculatorViewModel::onFiatInputChanged, - dismissNumberPadKey = dismissNumberPadKey, - onInputActiveChange = onInputActiveChange, - onInputSelected = calculatorViewModel::onInputSelected, - onInputDismissed = calculatorViewModel::onInputDismissed, - onNumberPadBoundsChanged = onNumberPadBoundsChanged, - ) -} - -@Composable -fun CalculatorCardEditor( - modifier: Modifier = Modifier, - btcPrimaryDisplayUnit: BitcoinDisplayUnit, - btcValue: String, - onBtcChange: (String) -> Unit, - fiatSymbol: String, - fiatName: String, - fiatValue: String, - onFiatChange: (String) -> Unit, - onInputSelected: (MoneyType) -> Unit, - onInputDismissed: () -> Unit, - dismissNumberPadKey: Int = 0, - onInputActiveChange: (Boolean) -> Unit = {}, - onNumberPadBoundsChanged: (Rect?) -> Unit = {}, -) { - val numpadState = rememberNumpadState() - val selectInput = { input: MoneyType -> - onInputSelected(input) - numpadState.selectInput(input) - } - - Column(modifier = modifier) { - Content( - btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, - btcValue = btcValue, - fiatSymbol = fiatSymbol, - fiatName = fiatName, - fiatValue = fiatValue, - activeInput = numpadState.activeInput, - onSelectInput = selectInput, - modifier = Modifier.fillMaxWidth(), - ) - - NumpadHost( - state = numpadState, - dismissNumberPadKey = dismissNumberPadKey, - onInputActiveChange = onInputActiveChange, - btcValue = btcValue, - btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, - fiatValue = fiatValue, - onBtcChange = onBtcChange, - onFiatChange = onFiatChange, - onInputDismissed = onInputDismissed, - onNumberPadBoundsChanged = onNumberPadBoundsChanged, - ) - } -} - -@Composable -private fun rememberNumpadState(): NumpadState { - val activeInput = rememberSaveable { mutableStateOf(null) } - val selectedInput = rememberSaveable { mutableStateOf(null) } - val visibilityState = remember { - MutableTransitionState(activeInput.value != null).apply { - targetState = activeInput.value != null - } - } - - return remember(activeInput, selectedInput, visibilityState) { - NumpadState( - activeInputState = activeInput, - selectedInputState = selectedInput, - visibilityState = visibilityState, - ) - } -} - -@Stable -private class NumpadState( - activeInputState: MutableState, - selectedInputState: MutableState, - val visibilityState: MutableTransitionState, -) { - var activeInput by activeInputState - private set - - var selectedInput by selectedInputState - private set - - var errorKey by mutableStateOf(null) - private set - - fun selectInput(input: MoneyType) { - selectedInput = input - activeInput = input - visibilityState.targetState = true - clearError() - } - - fun dismiss() { - activeInput = null - visibilityState.targetState = false - clearError() - } - - fun showError(key: String) { - errorKey = key - } - - fun clearError() { - errorKey = null - } -} - -@Composable -private fun ColumnScope.NumpadHost( - state: NumpadState, - dismissNumberPadKey: Int, - onInputActiveChange: (Boolean) -> Unit, - btcValue: String, - btcPrimaryDisplayUnit: BitcoinDisplayUnit, - fiatValue: String, - onBtcChange: (String) -> Unit, - onFiatChange: (String) -> Unit, - onInputDismissed: () -> Unit, - onNumberPadBoundsChanged: (Rect?) -> Unit, -) { - NumpadEffects( - state = state, - dismissNumberPadKey = dismissNumberPadKey, - onInputActiveChange = onInputActiveChange, - onInputDismissed = onInputDismissed, - onNumberPadBoundsChanged = onNumberPadBoundsChanged, - ) - - Numpad( - state = state, - btcValue = btcValue, - btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, - fiatValue = fiatValue, - onBtcChange = onBtcChange, - onFiatChange = onFiatChange, - onNumberPadBoundsChanged = onNumberPadBoundsChanged, - ) -} - -@Composable -private fun NumpadEffects( - state: NumpadState, - dismissNumberPadKey: Int, - onInputActiveChange: (Boolean) -> Unit, - onInputDismissed: () -> Unit, - onNumberPadBoundsChanged: (Rect?) -> Unit, -) { - val updatedOnInputActiveChange by rememberUpdatedState(onInputActiveChange) - val updatedOnInputDismissed by rememberUpdatedState(onInputDismissed) - val updatedOnNumberPadBoundsChanged by rememberUpdatedState(onNumberPadBoundsChanged) - val isInputTargetActive = state.visibilityState.targetState - - LaunchedEffect(dismissNumberPadKey) { state.dismiss() } - - LaunchedEffect(isInputTargetActive) { - updatedOnInputActiveChange(isInputTargetActive) - if (!isInputTargetActive) { - updatedOnInputDismissed() - updatedOnNumberPadBoundsChanged(null) - } - } - - LaunchedEffect(state.errorKey) { - if (state.errorKey == null) return@LaunchedEffect - delay(ERROR_DELAY) - state.clearError() - } - - DisposableEffect(Unit) { - onDispose { - updatedOnInputDismissed() - updatedOnInputActiveChange(false) - } - } -} - -@Composable -private fun ColumnScope.Numpad( - state: NumpadState, - btcValue: String, - btcPrimaryDisplayUnit: BitcoinDisplayUnit, - fiatValue: String, - onBtcChange: (String) -> Unit, - onFiatChange: (String) -> Unit, - onNumberPadBoundsChanged: (Rect?) -> Unit, -) { - val selectedInput = state.selectedInput - - AnimatedVisibility( - visibleState = state.visibilityState, - enter = EnterTransition.None, - exit = fadeOut() + shrinkVertically(), - ) { - selectedInput?.let { input -> - Column( - modifier = Modifier.onGloballyPositioned { - onNumberPadBoundsChanged(it.boundsInRoot()) - } - ) { - VerticalSpacer(8.dp) - NumberPad( - onPress = { key -> - val currentValue = currentInputValue( - input = input, - btcValue = btcValue, - fiatValue = fiatValue, - ) - val nextValue = nextInputValue( - input = input, - key = key, - btcValue = btcValue, - btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, - fiatValue = fiatValue, - ) - - if (nextValue == currentValue && key != KEY_DELETE) { - state.showError(key) - return@NumberPad - } - state.clearError() - - when (input) { - MoneyType.BITCOIN -> onBtcChange(nextValue) - MoneyType.FIAT -> onFiatChange(nextValue) - } - }, - type = when (input) { - MoneyType.BITCOIN if btcPrimaryDisplayUnit.isModern() -> NumberPadType.INTEGER - else -> NumberPadType.DECIMAL - }, - decimalSeparator = calculatorDecimalSeparator(), - errorKey = state.errorKey, - includeNavigationBarsPadding = true, - onDeleteLongPress = { - state.clearError() - when (input) { - MoneyType.BITCOIN -> onBtcChange("") - MoneyType.FIAT -> onFiatChange("") - } - }, - modifier = Modifier - .testTag("CalculatorNumberPad") - ) - NavBarSpacer(modifier = Modifier.background(MaterialTheme.colorScheme.background)) - } - } - } -} - -private fun currentInputValue( +internal fun currentInputValue( input: MoneyType, btcValue: String, fiatValue: String, @@ -351,7 +49,7 @@ private fun currentInputValue( MoneyType.FIAT -> fiatValue } -private fun nextInputValue( +internal fun nextInputValue( input: MoneyType, key: String, btcValue: String, @@ -381,7 +79,7 @@ private fun nextInputValue( } @Composable -private fun Content( +fun CalculatorCard( modifier: Modifier = Modifier, btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, @@ -430,8 +128,6 @@ private fun Content( } } -private val ERROR_DELAY = 500.milliseconds - @Composable fun CalculatorCardSmall( btcPrimaryDisplayUnit: BitcoinDisplayUnit, @@ -439,11 +135,14 @@ fun CalculatorCardSmall( fiatSymbol: String, fiatValue: String, modifier: Modifier = Modifier, + activeInput: MoneyType? = null, + onSelectInput: ((MoneyType) -> Unit)? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) .background(Colors.Gray6) .padding(16.dp) @@ -452,8 +151,11 @@ fun CalculatorCardSmall( ReadOnlyRow( currencySymbol = BITCOIN_SYMBOL, value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), + placeholder = formatBitcoinPlaceholder(btcValue, btcPrimaryDisplayUnit), iconSize = 24.dp, rowPadding = 12.dp, + isActive = activeInput == MoneyType.BITCOIN, + onClick = onSelectInput?.let { { it(MoneyType.BITCOIN) } }, modifier = Modifier .fillMaxWidth() .testTag("CalculatorSmallBtcRow") @@ -461,8 +163,11 @@ fun CalculatorCardSmall( ReadOnlyRow( currencySymbol = fiatSymbol, value = formatFiatValue(fiatValue), + placeholder = formatFiatPlaceholder(fiatValue), iconSize = 24.dp, rowPadding = 12.dp, + isActive = activeInput == MoneyType.FIAT, + onClick = onSelectInput?.let { { it(MoneyType.FIAT) } }, modifier = Modifier .fillMaxWidth() .testTag("CalculatorSmallFiatRow") @@ -477,6 +182,9 @@ private fun ReadOnlyRow( iconSize: Dp, rowPadding: Dp, modifier: Modifier = Modifier, + placeholder: String = "", + isActive: Boolean = false, + onClick: (() -> Unit)? = null, ) { val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() @@ -484,8 +192,9 @@ private fun ReadOnlyRow( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = modifier - .clip(MaterialTheme.shapes.small) + .clip(inputShape) .background(Colors.Black) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) .padding(rowPadding) ) { Box( @@ -501,10 +210,10 @@ private fun ReadOnlyRow( maxLines = 1, ) } - BodyMSB( - text = value, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + InputValue( + value = value, + placeholder = placeholder, + isActive = isActive, modifier = Modifier.weight(1f) ) } @@ -519,32 +228,15 @@ private fun Preview() { modifier = Modifier .padding(16.dp) ) { - CalculatorCardEditor( + CalculatorCard( btcValue = "1800000000", - onBtcChange = {}, fiatSymbol = "$", fiatValue = "4.55", fiatName = "USD", - onFiatChange = {}, - onInputSelected = {}, - onInputDismissed = {}, btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, modifier = Modifier.fillMaxWidth() ) - CalculatorCardEditor( - btcValue = "22200000", - onBtcChange = {}, - fiatSymbol = "$", - fiatValue = "4.55", - fiatName = "USD", - onFiatChange = {}, - onInputSelected = {}, - onInputDismissed = {}, - btcPrimaryDisplayUnit = BitcoinDisplayUnit.CLASSIC, - modifier = Modifier.fillMaxWidth() - ) - CalculatorCardSmall( btcValue = "10000", fiatValue = "6.25", diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt index 7d95d8361f..eada4b7c02 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorInput.kt @@ -36,13 +36,14 @@ import kotlinx.coroutines.delay import to.bitkit.ui.components.BodyMSB import to.bitkit.ui.components.CaptionB import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.AppTextStyles import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import kotlin.math.roundToInt import kotlin.time.Duration.Companion.milliseconds -val inputShape @Composable get() = MaterialTheme.shapes.small +val inputShape = AppShapes.small private val CURSOR_WIDTH = 2.dp private val CURSOR_HEIGHT = 22.dp private val CURSOR_BLINK_INTERVAL = 500.milliseconds @@ -96,7 +97,7 @@ fun CalculatorInput( } @Composable -private fun InputValue( +internal fun InputValue( value: String, placeholder: String, isActive: Boolean, diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorNumberPadBar.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorNumberPadBar.kt new file mode 100644 index 0000000000..06b1339238 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorNumberPadBar.kt @@ -0,0 +1,94 @@ +package to.bitkit.ui.screens.widgets.calculator.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.MaterialTheme +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.Modifier +import androidx.compose.ui.platform.testTag +import kotlinx.coroutines.delay +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.MoneyType +import to.bitkit.ui.components.KEY_DELETE +import to.bitkit.ui.components.NavBarSpacer +import to.bitkit.ui.components.NumberPad +import to.bitkit.ui.components.NumberPadType +import to.bitkit.ui.screens.widgets.calculator.calculatorDecimalSeparator +import kotlin.time.Duration.Companion.milliseconds + +private val ERROR_DELAY = 500.milliseconds + +/** + * Screen-bottom number pad for the home calculator widget, shown while an input is active. Edits the + * active value via [onBtcChange]/[onFiatChange], reusing the same key-handling as the inline editor. + * Mirrors iOS `CalculatorNumberPadBar`. + */ +@Composable +fun CalculatorNumberPadBar( + activeInput: MoneyType, + btcValue: String, + fiatValue: String, + btcPrimaryDisplayUnit: BitcoinDisplayUnit, + onBtcChange: (String) -> Unit, + onFiatChange: (String) -> Unit, + modifier: Modifier = Modifier, +) { + var errorKey by remember { mutableStateOf(null) } + + LaunchedEffect(errorKey) { + if (errorKey == null) return@LaunchedEffect + delay(ERROR_DELAY) + errorKey = null + } + + Column(modifier = modifier.background(MaterialTheme.colorScheme.background)) { + NumberPad( + onPress = { key -> + val currentValue = currentInputValue( + input = activeInput, + btcValue = btcValue, + fiatValue = fiatValue, + ) + val nextValue = nextInputValue( + input = activeInput, + key = key, + btcValue = btcValue, + btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, + fiatValue = fiatValue, + ) + + if (nextValue == currentValue && key != KEY_DELETE) { + errorKey = key + return@NumberPad + } + errorKey = null + + when (activeInput) { + MoneyType.BITCOIN -> onBtcChange(nextValue) + MoneyType.FIAT -> onFiatChange(nextValue) + } + }, + type = when (activeInput) { + MoneyType.BITCOIN if btcPrimaryDisplayUnit.isModern() -> NumberPadType.INTEGER + else -> NumberPadType.DECIMAL + }, + decimalSeparator = calculatorDecimalSeparator(), + errorKey = errorKey, + includeNavigationBarsPadding = true, + onDeleteLongPress = { + errorKey = null + when (activeInput) { + MoneyType.BITCOIN -> onBtcChange("") + MoneyType.FIAT -> onFiatChange("") + } + }, + modifier = Modifier.testTag("CalculatorNumberPad") + ) + NavBarSpacer(modifier = Modifier.background(MaterialTheme.colorScheme.background)) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt new file mode 100644 index 0000000000..01edc59e0d --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt @@ -0,0 +1,193 @@ +package to.bitkit.ui.screens.widgets.components + +import android.os.Build +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.detectTapGestures +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.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.BlurredEdgeTreatment +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.blur +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import to.bitkit.R +import to.bitkit.models.WidgetType +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.Colors + +private val SETTINGS_DISABLED_TYPES = setOf(WidgetType.SUGGESTIONS) + +/** + * Editing chrome for a home widget cell: blurs the underlying card, lays a gray scrim and dashed + * brand border over it, and centers the widget name with delete / settings / reorder actions. + * Mirrors iOS `BaseWidget` editing overlay. [content] is the real card so the cell keeps the same + * footprint in display and edit mode. + */ +@Composable +fun EditWidgetOverlay( + type: WidgetType, + onDelete: () -> Unit, + onSettings: () -> Unit, + dragHandleModifier: Modifier, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + val name = stringResource(type.title) + val settingsEnabled = type !in SETTINGS_DISABLED_TYPES + + Box( + modifier = modifier + .clip(RoundedCornerShape(CARD_CORNER_RADIUS)) + .background(Colors.Gray6) + .dashedBorder() + ) { + // The card defines the cell size; the blur, scrim and actions overlay on top of it. + Box(modifier = Modifier.editBlur()) { + content() + } + + // Scrim sits above the blurred card and swallows taps so the card stays inert while editing, + // while letting vertical drags fall through to the page scroll. + Box( + modifier = Modifier + .matchParentSize() + .background(Colors.Gray6.copy(alpha = 0.8f)) + .pointerInput(Unit) { + detectTapGestures { /* swallow taps on the blurred card */ } + } + ) + + EditActions( + name = name, + settingsEnabled = settingsEnabled, + onDelete = onDelete, + onSettings = onSettings, + dragHandleModifier = dragHandleModifier, + modifier = Modifier.matchParentSize() + ) + } +} + +private val CARD_CORNER_RADIUS = 16.dp + +@Composable +private fun EditActions( + name: String, + settingsEnabled: Boolean, + onDelete: () -> Unit, + onSettings: () -> Unit, + dragHandleModifier: Modifier, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier.padding(8.dp) + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally + ) { + BodyMSB( + text = name, + textAlign = TextAlign.Center, + modifier = Modifier.testTag("${name}_drag_and_drop_title") + ) + + VerticalSpacer(12.dp) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + EditActionIcon( + iconRes = R.drawable.ic_trash, + contentDescription = stringResource(R.string.common__delete), + onClick = onDelete, + modifier = Modifier.testTag("${name}_WidgetActionDelete") + ) + + EditActionIcon( + iconRes = R.drawable.ic_settings, + contentDescription = stringResource(R.string.common__edit), + onClick = onSettings, + enabled = settingsEnabled, + modifier = Modifier.testTag("${name}_WidgetActionEdit") + ) + + EditActionIcon( + iconRes = R.drawable.ic_arrows_out_cardinal, + contentDescription = null, + onClick = null, + modifier = dragHandleModifier.testTag("${name}_WidgetActionDrag") + ) + } + } + } +} + +@Composable +private fun EditActionIcon( + iconRes: Int, + contentDescription: String?, + onClick: (() -> Unit)?, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(32.dp) + .then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier) + ) { + Icon( + painter = painterResource(iconRes), + contentDescription = contentDescription, + modifier = Modifier + .size(24.dp) + .alpha(if (enabled) 1f else DISABLED_ALPHA) + ) + } +} + +private const val DISABLED_ALPHA = 0.3f + +private fun Modifier.editBlur(): Modifier = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + this.blur(4.dp, BlurredEdgeTreatment(RoundedCornerShape(CARD_CORNER_RADIUS))) + } else { + this + } + +private fun Modifier.dashedBorder(): Modifier = drawWithContent { + drawContent() + val strokeWidth = 2.dp.toPx() + val inset = strokeWidth / 2f + drawRoundRect( + color = Colors.Brand, + topLeft = Offset(inset, inset), + size = Size(size.width - strokeWidth, size.height - strokeWidth), + cornerRadius = CornerRadius(CARD_CORNER_RADIUS.toPx() - inset), + style = Stroke( + width = strokeWidth, + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 4.dp.toPx())), + ), + ) +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt new file mode 100644 index 0000000000..978605d210 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt @@ -0,0 +1,253 @@ +package to.bitkit.ui.screens.widgets.components + +import androidx.compose.animation.BoundsTransform +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateBounds +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.PathEffect +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LookaheadScope +import androidx.compose.ui.layout.boundsInRoot +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInRoot +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList +import to.bitkit.R +import to.bitkit.models.WidgetSize +import to.bitkit.models.WidgetType +import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.effectiveSize +import to.bitkit.ui.components.BodyMSB +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.theme.Colors +import kotlin.math.roundToInt + +private const val DRAGGED_ALPHA = 0.3f +private const val DRAG_PREVIEW_ALPHA = 0.8f +private const val REORDER_DAMPING = 0.85f + +@OptIn(ExperimentalSharedTransitionApi::class) +private val ReorderBoundsTransform = BoundsTransform { _, _ -> + spring(dampingRatio = REORDER_DAMPING, stiffness = Spring.StiffnessMediumLow) +} + +/** + * Edit-mode home grid: renders the real widget cards in the two-column flow layout wrapped in the + * dashed editing overlay, with location-based drag reordering (a floating preview follows the + * finger) and spring placement animations mirroring iOS. + */ +@OptIn(ExperimentalSharedTransitionApi::class) +@Composable +fun EditableWidgetGrid( + items: ImmutableList, + onMove: (from: Int, to: Int) -> Unit, + onDelete: (WidgetType) -> Unit, + onSettings: (WidgetType) -> Unit, + modifier: Modifier = Modifier, + cardContent: @Composable (WidgetWithPosition) -> Unit, +) { + val haptic = LocalHapticFeedback.current + val density = LocalDensity.current + val cellBounds = remember { mutableStateMapOf() } + var gridOrigin by remember { mutableStateOf(Offset.Zero) } + var draggedType by remember { mutableStateOf(null) } + var dragPointer by remember { mutableStateOf(Offset.Zero) } + // The last slot we reordered onto, so we only act when the target changes (mirrors iOS). + var lastTarget by remember { mutableStateOf(null) } + val latestItems by rememberUpdatedState(items) + + val isWide = items.map { it.effectiveSize() == WidgetSize.WIDE }.toImmutableList() + + Box(modifier = modifier.onGloballyPositioned { gridOrigin = it.positionInRoot() }) { + LookaheadScope { + WidgetFlowLayout(isWide = isWide, modifier = Modifier.fillMaxWidth()) { + items.forEach { widget -> + key(widget.type) { + val isDragging = draggedType == widget.type + val dragHandleModifier = Modifier.pointerInput(widget.type) { + detectReorderDrag( + type = widget.type, + cellBounds = cellBounds, + onStart = { center -> + draggedType = widget.type + dragPointer = center + lastTarget = null + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { delta -> + dragPointer += delta + val source = draggedType + val target = nearestWidgetSlot(dragPointer, cellBounds) + when { + source == null || target == null -> Unit + // back over dragged cell: re-allow visited slots + target == source -> lastTarget = null + // act only when target changes (avoid oscillation) + target != lastTarget -> { + val from = latestItems.indexOfFirst { it.type == source } + val to = latestItems.indexOfFirst { it.type == target } + if (from >= 0 && to >= 0) { + onMove(from, to) + lastTarget = target + haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } + } + }, + onEnd = { + draggedType = null + lastTarget = null + }, + ) + } + + Box( + modifier = Modifier + .animateBounds(this@LookaheadScope, boundsTransform = ReorderBoundsTransform) + .onGloballyPositioned { cellBounds[widget.type] = it.boundsInRoot() } + .alpha(if (isDragging) DRAGGED_ALPHA else 1f) + ) { + EditWidgetOverlay( + type = widget.type, + onDelete = { onDelete(widget.type) }, + onSettings = { onSettings(widget.type) }, + dragHandleModifier = dragHandleModifier, + modifier = Modifier.fillMaxSize() + ) { + cardContent(widget) + } + } + } + } + } + } + + draggedType?.let { type -> + val bounds = cellBounds[type] ?: return@let + Box( + modifier = Modifier + .zIndex(1f) + .offset { + IntOffset( + x = (dragPointer.x - gridOrigin.x - bounds.width / 2f).roundToInt(), + y = (dragPointer.y - gridOrigin.y - bounds.height / 2f).roundToInt(), + ) + } + .size( + width = with(density) { bounds.width.toDp() }, + height = with(density) { bounds.height.toDp() }, + ) + .alpha(DRAG_PREVIEW_ALPHA) + ) { + DragPreviewCard(type = type, modifier = Modifier.fillMaxSize()) + } + } + } +} + +private suspend fun PointerInputScope.detectReorderDrag( + type: WidgetType, + cellBounds: Map, + onStart: (center: Offset) -> Unit, + onDrag: (delta: Offset) -> Unit, + onEnd: () -> Unit, +) { + detectDragGestures( + onDragStart = { onStart(cellBounds[type]?.center ?: Offset.Zero) }, + onDrag = { change, dragAmount -> + change.consume() + onDrag(dragAmount) + }, + onDragEnd = onEnd, + onDragCancel = onEnd, + ) +} + +@Composable +private fun DragPreviewCard( + type: WidgetType, + modifier: Modifier = Modifier, +) { + val name = stringResource(type.title) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .clip(RoundedCornerShape(16.dp)) + .background(Colors.Gray6) + .drawBehind { + drawRoundRect( + color = Colors.Brand, + cornerRadius = CornerRadius(16.dp.toPx()), + style = Stroke( + width = 2.dp.toPx(), + pathEffect = PathEffect.dashPathEffect(floatArrayOf(6.dp.toPx(), 4.dp.toPx())), + ), + ) + } + .padding(8.dp) + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + BodyMSB(text = name, textAlign = TextAlign.Center) + VerticalSpacer(12.dp) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf( + R.drawable.ic_trash, + R.drawable.ic_settings, + R.drawable.ic_arrows_out_cardinal, + ).forEach { icon -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(32.dp) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + } + } + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt new file mode 100644 index 0000000000..c690e40b2a --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt @@ -0,0 +1,182 @@ +package to.bitkit.ui.screens.widgets.components + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.ImmutableList + +/** A placed widget in the home grid: the child's index and its frame within the grid bounds (px). */ +data class WidgetSlot( + val index: Int, + val x: Int, + val y: Int, + val width: Int, + val height: Int, +) + +data class WidgetGridResult( + val slots: List, + val totalHeight: Int, +) + +/** + * Pure two-column flow geometry (px). Wide widgets span the full width on their own row; consecutive + * small widgets pair side by side (a lone trailing small occupies the left column). Paired smalls + * share the taller of the two heights. Mirrors iOS `widgetGridSlots`. + */ +fun widgetGridSlots( + isWide: List, + totalWidth: Int, + spacingPx: Int, + heights: List, + smallHeightPx: Int, +): WidgetGridResult { + val columnWidth = (totalWidth - spacingPx) / 2 + val slots = ArrayList(isWide.size) + var y = 0 + var i = 0 + + while (i < isWide.size) { + when { + isWide[i] -> { + val height = heightAt(heights, i, smallHeightPx) + slots.add(fullWidthSlot(index = i, y = y, totalWidth = totalWidth, height = height)) + y += height + spacingPx + i += 1 + } + + canPairWithNext(isWide, i) -> { + val rowHeight = maxOf( + heightAt(heights, i, smallHeightPx), + heightAt(heights, i + 1, smallHeightPx), + ) + slots.addAll( + pairedRowSlots( + index = i, + y = y, + columnWidth = columnWidth, + spacingPx = spacingPx, + rowHeight = rowHeight, + ) + ) + y += rowHeight + spacingPx + i += 2 + } + + else -> { + val height = heightAt(heights, i, smallHeightPx) + slots.add(leftColumnSlot(index = i, y = y, columnWidth = columnWidth, height = height)) + y += height + spacingPx + i += 1 + } + } + } + + return WidgetGridResult(slots = slots, totalHeight = maxOf(0, y - spacingPx)) +} + +private fun heightAt(heights: List, index: Int, fallback: Int): Int = + heights.getOrElse(index) { fallback } + +private fun canPairWithNext(isWide: List, index: Int): Boolean { + val next = index + 1 + val nextExists = next < isWide.size + return nextExists && !isWide[next] +} + +private fun fullWidthSlot(index: Int, y: Int, totalWidth: Int, height: Int): WidgetSlot = + WidgetSlot(index = index, x = 0, y = y, width = totalWidth, height = height) + +private fun leftColumnSlot(index: Int, y: Int, columnWidth: Int, height: Int): WidgetSlot = + WidgetSlot(index = index, x = 0, y = y, width = columnWidth, height = height) + +private fun pairedRowSlots(index: Int, y: Int, columnWidth: Int, spacingPx: Int, rowHeight: Int): List = + listOf( + WidgetSlot(index = index, x = 0, y = y, width = columnWidth, height = rowHeight), + WidgetSlot( + index = index + 1, + x = columnWidth + spacingPx, + y = y, + width = columnWidth, + height = rowHeight, + ), + ) + +/** + * Resolves which widget cell a drag point targets, given each cell's frame (grid coords). Picks the + * nearest by vertical band distance, then horizontal band distance, then biases toward the lower + * slot so a drop in an inter-row gap targets the row below. Mirrors iOS `nearestWidgetSlot`. + */ +fun nearestWidgetSlot(finger: Offset, bounds: Map): K? { + if (bounds.isEmpty()) return null + return bounds.entries + .minWithOrNull( + compareBy( + { axisDistance(finger.y, it.value.top, it.value.bottom) }, + { axisDistance(finger.x, it.value.left, it.value.right) }, + { -it.value.top }, + ) + ) + ?.key +} + +private fun axisDistance(value: Float, min: Float, max: Float): Float = when { + value < min -> min - value + value > max -> value - max + else -> 0f +} + +/** + * Two-column flow layout for the home widget grid. [isWide] is parallel to [content]'s children + * order. Small children are measured at a fixed column width and [smallHeight]; wide children span + * the full width with free height. + */ +@Composable +fun WidgetFlowLayout( + isWide: ImmutableList, + modifier: Modifier = Modifier, + spacing: Dp = 16.dp, + smallHeight: Dp = WidgetCardDimens.COMPACT_CARD_SIZE.height, + content: @Composable () -> Unit, +) { + Layout(content = content, modifier = modifier) { measurables, constraints -> + val spacingPx = spacing.roundToPx() + val smallHeightPx = smallHeight.roundToPx() + val totalWidth = constraints.maxWidth + val columnWidth = (totalWidth - spacingPx) / 2 + val wideFlags = measurables.indices.map { isWide.getOrElse(it) { true } } + + val placeables = measurables.mapIndexed { index, measurable -> + if (wideFlags[index]) { + measurable.measure( + constraints.copy(minWidth = totalWidth, maxWidth = totalWidth, minHeight = 0) + ) + } else { + measurable.measure(Constraints.fixed(columnWidth, smallHeightPx)) + } + } + + val heights = placeables.mapIndexed { index, placeable -> + if (wideFlags[index]) placeable.height else smallHeightPx + } + + val result = widgetGridSlots( + isWide = wideFlags, + totalWidth = totalWidth, + spacingPx = spacingPx, + heights = heights, + smallHeightPx = smallHeightPx, + ) + + layout(totalWidth, result.totalHeight) { + result.slots.forEach { slot -> + placeables[slot.index].placeRelative(slot.x, slot.y) + } + } + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt index 743f324914..180cd1ede6 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt @@ -12,30 +12,60 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.drop +import to.bitkit.R +import to.bitkit.models.WidgetSize +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.theme.Colors -private const val PAGE_WIDE = 0 -// private const val PAGE_SMALL = 1 - -// temporarily removed until small size widgets variants are implemented -private const val PAGE_COUNT = 1 -// private const val PAGE_COUNT = 2 - @Composable -@Suppress("UnusedParameter") fun WidgetSizeCarousel( smallContent: @Composable () -> Unit, wideContent: @Composable () -> Unit, modifier: Modifier = Modifier, + supportsSmall: Boolean = true, + initialSize: WidgetSize = WidgetSize.WIDE, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { + val pageCount = if (supportsSmall) 2 else 1 val pagerState = rememberPagerState( - pageCount = { PAGE_COUNT }, + initialPage = initialPageFor(initialSize, supportsSmall), + pageCount = { pageCount }, ) + val currentOnSizeSelected by rememberUpdatedState(onSizeSelected) + + // The persisted size can resolve asynchronously after first composition (DataStore read), so + // realign the pager once it arrives instead of leaving it on the placeholder default page. + // Guarded so a user swipe (which flows back in via [initialSize]) never fights the gesture. + LaunchedEffect(initialSize, supportsSmall) { + val targetPage = initialPageFor(initialSize, supportsSmall) + if (pagerState.currentPage != targetPage) { + pagerState.scrollToPage(targetPage) + } + } + + // Report only genuine page changes; dropping the initial emission keeps opening the sheet from + // overwriting the persisted size with the placeholder default before it has resolved. + LaunchedEffect(pagerState, supportsSmall) { + snapshotFlow { pagerState.currentPage } + .drop(1) + .collect { page -> + currentOnSizeSelected(pageToSize(page, supportsSmall)) + } + } Column( verticalArrangement = Arrangement.Center, @@ -44,48 +74,51 @@ fun WidgetSizeCarousel( HorizontalPager( state = pagerState, modifier = Modifier - .fillMaxWidth() .weight(1f) + .bleedHorizontal(PAGER_HORIZONTAL_PADDING) + .fillMaxWidth() .testTag("widget_size_pager") ) { page -> Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PAGER_HORIZONTAL_PADDING) ) { - when (page) { - PAGE_WIDE -> wideContent() - // temporarily removed until small size widgets variants are implemented - // PAGE_SMALL -> smallContent() + when (pageToSize(page, supportsSmall)) { + WidgetSize.SMALL -> smallContent() + WidgetSize.WIDE -> wideContent() } } } VerticalSpacer(16.dp) - // temporarily removed until small size widgets variants are implemented - // Caption13Up( - // text = stringResource( - // when (pagerState.currentPage) { - // PAGE_SMALL -> R.string.widgets__widget__size_small - // else -> R.string.widgets__widget__size_wide - // }, - // ), - // color = Colors.White64, - // textAlign = TextAlign.Center, - // modifier = Modifier - // .fillMaxWidth() - // .testTag("widget_size_label") - // ) + + Caption13Up( + text = stringResource( + when (pageToSize(pagerState.currentPage, supportsSmall)) { + WidgetSize.SMALL -> R.string.widgets__widget__size_small + WidgetSize.WIDE -> R.string.widgets__widget__size_wide + }, + ), + color = Colors.White64, + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + .testTag("widget_size_label") + ) VerticalSpacer(16.dp) - if (PAGE_COUNT > 1) { + if (pageCount > 1) { Row( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() + .padding(vertical = 6.dp) .testTag("page_indicator") ) { - repeat(PAGE_COUNT) { index -> + repeat(pageCount) { index -> Box( modifier = Modifier .padding(horizontal = 4.dp) @@ -100,3 +133,26 @@ fun WidgetSizeCarousel( } } } + +private fun Modifier.bleedHorizontal(padding: Dp): Modifier = layout { measurable, constraints -> + val bleed = padding.roundToPx() + val placeable = measurable.measure(constraints.copy(maxWidth = constraints.maxWidth + bleed * 2)) + layout(constraints.maxWidth, placeable.height) { + placeable.place(-bleed, 0) + } +} + +private fun pageToSize(page: Int, supportsSmall: Boolean): WidgetSize { + if (!supportsSmall) return WidgetSize.WIDE + return if (page == PAGE_SMALL) WidgetSize.SMALL else WidgetSize.WIDE +} + +private fun initialPageFor(size: WidgetSize, supportsSmall: Boolean): Int { + if (!supportsSmall) return 0 + return if (size == WidgetSize.WIDE) PAGE_WIDE else PAGE_SMALL +} + +private const val PAGE_SMALL = 0 +private const val PAGE_WIDE = 1 + +private val PAGER_HORIZONTAL_PADDING = 16.dp diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt index 0d422750bc..70eef7ec54 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt @@ -7,6 +7,7 @@ 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.shape.CircleShape @@ -33,12 +34,11 @@ import to.bitkit.ui.theme.Colors fun FactsCard( headline: String, modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, ) { Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Row( horizontalArrangement = Arrangement.spacedBy(20.dp), @@ -64,13 +64,13 @@ fun FactsCard( fun FactsCardSmall( headline: String, modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, ) { Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( modifier = Modifier @@ -141,9 +141,11 @@ private fun PreviewSmall() { ) { FactsCardSmall( headline = "Bitcoin doesn’t need your personal information", + modifier = Modifier.weight(1f) ) FactsCardSmall( headline = "Priced in Bitcoin", + modifier = Modifier.weight(1f) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt index 1f48a18ae8..a38805c5ef 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,11 +17,13 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.WidgetSize import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -36,6 +39,7 @@ fun FactsPreviewScreen( ) { val fact by factsViewModel.currentFact.collectAsStateWithLifecycle() val isFactsWidgetEnabled by factsViewModel.isFactsWidgetEnabled.collectAsStateWithLifecycle() + val draftSize by factsViewModel.draftSize.collectAsStateWithLifecycle() LaunchedEffect(Unit) { factsViewModel.refreshOnDisplay() @@ -51,6 +55,8 @@ fun FactsPreviewScreen( onClickSave = { factsViewModel.saveWidget(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = factsViewModel::setSize, modifier = modifier ) } @@ -63,6 +69,8 @@ fun FactsPreviewContent( isFactsWidgetEnabled: Boolean, fact: String, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -97,7 +105,9 @@ fun FactsPreviewContent( smallContent = { FactsCardSmall( headline = fact, - modifier = Modifier.testTag("facts_card_small") + modifier = Modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .testTag("facts_card_small") ) }, wideContent = { @@ -108,6 +118,8 @@ fun FactsPreviewContent( .testTag("facts_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("facts_preview_carousel") @@ -121,7 +133,7 @@ fun FactsPreviewContent( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt index f3751e0ee1..b126bc9b81 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt @@ -8,8 +8,10 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import javax.inject.Inject @HiltViewModel @@ -27,6 +29,11 @@ class FactsViewModel @Inject constructor( initialValue = false ) + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.FACTS, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + val currentFact: StateFlow = widgetsRepo.factsFlow.map { facts -> facts.randomOrNull() ?: DEFAULT_FACT }.stateIn( scope = viewModelScope, @@ -36,7 +43,7 @@ class FactsViewModel @Inject constructor( fun saveWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { - widgetsRepo.addWidget(WidgetType.FACTS) + widgetsRepo.addWidget(WidgetType.FACTS, sizeDraft.current) onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt index 43899fbcf4..27fd183f7d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt @@ -8,13 +8,12 @@ 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.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow @@ -31,7 +30,6 @@ import to.bitkit.ui.theme.Colors @Composable fun HeadlineCard( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, showTime: Boolean = true, showSource: Boolean = true, time: String, @@ -45,7 +43,7 @@ fun HeadlineCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) .clickableAlpha(enabled = enabled) { val uri = safeBrowserUri(link) ?: return@clickableAlpha val intent = Intent(Intent.ACTION_VIEW, uri) @@ -96,7 +94,6 @@ fun HeadlineCard( @Composable fun HeadlineCardSmall( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, showTime: Boolean = true, time: String, headline: String, @@ -106,9 +103,10 @@ fun HeadlineCardSmall( Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) .clickableAlpha { val uri = safeBrowserUri(link) ?: return@clickableAlpha val intent = Intent(Intent.ACTION_VIEW, uri) @@ -202,13 +200,15 @@ private fun PreviewSmall() { HeadlineCardSmall( time = "21 min ago", headline = "How Bitcoin changed El Salvador in more ways", - link = "" + link = "", + modifier = Modifier.weight(1f) ) HeadlineCardSmall( showTime = false, time = "21 min ago", headline = "How Bitcoin changed El Salvador", - link = "" + link = "", + modifier = Modifier.weight(1f) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt index cc8962c41c..695bac31c4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,6 +17,7 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R +import to.bitkit.models.WidgetSize import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.ui.components.BodyM @@ -25,6 +27,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -42,6 +45,7 @@ fun HeadlinesPreviewScreen( val customHeadlinePreferences by headlinesViewModel.customPreferences.collectAsStateWithLifecycle() val article by headlinesViewModel.currentArticle.collectAsStateWithLifecycle() val isHeadlinesImplemented by headlinesViewModel.isNewsWidgetEnabled.collectAsStateWithLifecycle() + val draftSize by headlinesViewModel.draftSize.collectAsStateWithLifecycle() LaunchedEffect(Unit) { headlinesViewModel.refreshOnDisplay() @@ -59,6 +63,8 @@ fun HeadlinesPreviewScreen( onClickSave = { headlinesViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = headlinesViewModel::setSize, modifier = modifier ) } @@ -73,6 +79,8 @@ fun HeadlinesPreviewContent( headlinePreferences: HeadlinePreferences, article: ArticleModel, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.WIDE, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -123,7 +131,9 @@ fun HeadlinesPreviewContent( time = article.timeAgo, headline = article.title, link = article.link, - modifier = Modifier.testTag("headline_card_small") + modifier = Modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .testTag("headline_card_small") ) }, wideContent = { @@ -139,6 +149,8 @@ fun HeadlinesPreviewContent( .testTag("headline_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("headlines_preview_carousel") @@ -152,7 +164,7 @@ fun HeadlinesPreviewContent( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt index e8227ba646..bcab0cd824 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt @@ -11,11 +11,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.toArticleModel import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import javax.inject.Inject @HiltViewModel @@ -56,6 +58,11 @@ class HeadlinesViewModel @Inject constructor( private val _customPreferences = MutableStateFlow(HeadlinePreferences()) val customPreferences: StateFlow = _customPreferences.asStateFlow() + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.NEWS, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + init { initializeCustomPreferences() } @@ -81,7 +88,7 @@ class HeadlinesViewModel @Inject constructor( fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateHeadlinePreferences(_customPreferences.value) - widgetsRepo.addWidget(WidgetType.NEWS) + widgetsRepo.addWidget(WidgetType.NEWS, sizeDraft.current) onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt index ac726b6e5b..26552e5a7a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt @@ -19,7 +19,6 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode @@ -55,7 +54,6 @@ fun PriceCard( pricePreferences: PricePreferences, priceDTO: PriceDTO, modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, ) { val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { priceDTO.resolveWidget(pricePreferences) @@ -64,7 +62,7 @@ fun PriceCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -117,7 +115,6 @@ fun PriceCardSmall( pricePreferences: PricePreferences, priceDTO: PriceDTO, modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, ) { val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { priceDTO.resolveWidget(pricePreferences) @@ -126,7 +123,7 @@ fun PriceCardSmall( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt index b884a9f332..68e30dadad 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt @@ -22,6 +22,7 @@ import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.PriceWidgetData import to.bitkit.data.dto.price.TradingPair +import to.bitkit.models.WidgetSize import to.bitkit.models.widget.PricePreferences import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.FillHeight @@ -51,6 +52,7 @@ fun PricePreviewScreen( val previewPrice by priceViewModel.previewPrice.collectAsStateWithLifecycle() val isPriceWidgetEnabled by priceViewModel.isPriceWidgetEnabled.collectAsStateWithLifecycle() val isLoading by priceViewModel.isLoading.collectAsStateWithLifecycle() + val draftSize by priceViewModel.draftSize.collectAsStateWithLifecycle() LaunchedEffect(Unit) { priceViewModel.refreshOnDisplay() @@ -77,6 +79,8 @@ fun PricePreviewScreen( priceViewModel.savePreferences() }, isLoading = isLoading, + initialSize = draftSize, + onSizeSelected = priceViewModel::setSize, modifier = modifier ) } @@ -92,6 +96,8 @@ fun PricePreviewContent( priceDTO: PriceDTO?, isLoading: Boolean, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.WIDE, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -155,6 +161,8 @@ fun PricePreviewContent( .testTag("price_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .weight(1f) @@ -172,7 +180,7 @@ fun PricePreviewContent( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt index 8a17a774ca..1ebbc3f413 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt @@ -20,9 +20,11 @@ import kotlinx.coroutines.launch import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.dto.price.TradingPair +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.widget.PricePreferences import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import to.bitkit.utils.Logger import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -60,6 +62,11 @@ class PriceViewModel @Inject constructor( private val _customPreferences = MutableStateFlow(PricePreferences()) val customPreferences: StateFlow = _customPreferences.asStateFlow() + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.PRICE, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + private val _allPrices = MutableStateFlow>(persistentListOf()) private val _previewPrice: MutableStateFlow = MutableStateFlow(null) @@ -99,7 +106,7 @@ class PriceViewModel @Inject constructor( viewModelScope.launch { _isLoading.update { true } widgetsRepo.updatePricePreferences(_customPreferences.value) - widgetsRepo.addWidget(WidgetType.PRICE) + widgetsRepo.addWidget(WidgetType.PRICE, sizeDraft.current) widgetsRepo.refreshWidget(WidgetType.PRICE) _previewPrice.update { null } setPriceEffect(PriceEffect.NavigateHome) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherCard.kt index 1f7d114ef9..3566ecfb4e 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherCard.kt @@ -8,8 +8,8 @@ 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.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -90,7 +90,8 @@ fun WeatherCardSmall( ) { Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) .background(Colors.Gray6) .testTag("weather_card_small") @@ -303,6 +304,7 @@ private fun PreviewSmall() { icon = FeeCondition.GOOD.icon, ), preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + modifier = Modifier.weight(1f) ) WeatherCardSmall( weatherModel = WeatherModel( @@ -317,6 +319,7 @@ private fun PreviewSmall() { icon = FeeCondition.AVERAGE.icon, ), preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + modifier = Modifier.weight(1f) ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt index 0d8f29f447..bd8aa67fd3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -17,6 +18,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.WidgetSize import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.components.BodyM @@ -27,6 +29,7 @@ import to.bitkit.ui.components.settings.SettingsButtonRow import to.bitkit.ui.components.settings.SettingsButtonValue import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -44,6 +47,7 @@ fun WeatherPreviewScreen( val customWeatherPreferences by weatherViewModel.customPreferences.collectAsStateWithLifecycle() val weather by weatherViewModel.currentWeather.collectAsStateWithLifecycle() val isWeatherWidgetEnabled by weatherViewModel.isWeatherWidgetEnabled.collectAsStateWithLifecycle() + val draftSize by weatherViewModel.draftSize.collectAsStateWithLifecycle() LaunchedEffect(Unit) { weatherViewModel.refreshOnDisplay() @@ -61,6 +65,8 @@ fun WeatherPreviewScreen( onClickSave = { weatherViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = weatherViewModel::setSize, modifier = modifier ) } @@ -75,6 +81,8 @@ fun WeatherPreviewContent( weatherPreferences: WeatherPreferences, weatherModel: WeatherModel?, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -124,7 +132,9 @@ fun WeatherPreviewContent( WeatherCardSmall( weatherModel = model, preferences = weatherPreferences, - modifier = Modifier.testTag("weather_card_small") + modifier = Modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .testTag("weather_card_small") ) }, wideContent = { @@ -136,6 +146,8 @@ fun WeatherPreviewContent( .testTag("weather_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("weather_preview_carousel") @@ -150,7 +162,7 @@ fun WeatherPreviewContent( start = 16.dp, end = 16.dp, bottom = Insets.Bottom + 16.dp, - top = 22.dp, + top = 16.dp, ) .fillMaxWidth() .testTag("buttons_row") diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherViewModel.kt index 24183c0cef..07d67cb40d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherViewModel.kt @@ -12,11 +12,13 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.models.WidgetSize import to.bitkit.models.WidgetType import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.WidgetsRepo +import to.bitkit.ui.screens.widgets.WidgetSizeDraft import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.screens.widgets.blocks.toWeatherModel import javax.inject.Inject @@ -74,6 +76,11 @@ class WeatherViewModel @Inject constructor( private val _customPreferences = MutableStateFlow(WeatherPreferences()) val customPreferences: StateFlow = _customPreferences.asStateFlow() + private val sizeDraft = WidgetSizeDraft(viewModelScope, WidgetType.WEATHER, widgetsRepo.widgetsDataFlow) + val draftSize: StateFlow = sizeDraft.size + + fun setSize(size: WidgetSize) = sizeDraft.set(size) + init { initializeCustomPreferences() } @@ -94,7 +101,7 @@ class WeatherViewModel @Inject constructor( fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateWeatherPreferences(_customPreferences.value) - widgetsRepo.addWidget(WidgetType.WEATHER) + widgetsRepo.addWidget(WidgetType.WEATHER, sizeDraft.current) onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt index 49e3576cd5..7b8fd47cdf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt @@ -131,6 +131,7 @@ private fun WidgetsSheetContent( val article by galleryViewModel.currentArticle.collectAsStateWithLifecycle() val fact by galleryViewModel.currentFact.collectAsStateWithLifecycle() val price by galleryViewModel.currentPrice.collectAsStateWithLifecycle() + val calculatorValues by galleryViewModel.currentCalculatorValues.collectAsStateWithLifecycle() LaunchedEffect(Unit) { galleryViewModel.refreshOnDisplay() @@ -149,6 +150,7 @@ private fun WidgetsSheetContent( block = block, fact = fact, price = price, + calculatorValues = calculatorValues, modifier = Modifier ) } @@ -169,7 +171,13 @@ private fun WidgetsSheetContent( PriceEditScreen( viewModel = priceViewModel, onBack = { navController.popOrDismiss(onDismiss) }, - navigatePreview = { navController.popBackStack() }, + navigatePreview = { + if (navController.previousBackStackEntry == null) { + navController.navigateTo(WidgetsRoute.PricePreview) + } else { + navController.popBackStack() + } + }, modifier = Modifier.widgetSheetPage() ) } @@ -190,7 +198,13 @@ private fun WidgetsSheetContent( WeatherEditScreen( weatherViewModel = weatherViewModel, onBack = { navController.popOrDismiss(onDismiss) }, - navigatePreview = { navController.popBackStack() }, + navigatePreview = { + if (navController.previousBackStackEntry == null) { + navController.navigateTo(WidgetsRoute.WeatherPreview) + } else { + navController.popBackStack() + } + }, modifier = Modifier.widgetSheetPage() ) } @@ -211,7 +225,13 @@ private fun WidgetsSheetContent( BlocksEditScreen( blocksViewModel = blocksViewModel, onBack = { navController.popOrDismiss(onDismiss) }, - navigatePreview = { navController.popBackStack() }, + navigatePreview = { + if (navController.previousBackStackEntry == null) { + navController.navigateTo(WidgetsRoute.BlocksPreview) + } else { + navController.popBackStack() + } + }, modifier = Modifier.widgetSheetPage() ) } @@ -236,7 +256,13 @@ private fun WidgetsSheetContent( HeadlinesEditScreen( headlinesViewModel = headlinesViewModel, onBack = { navController.popOrDismiss(onDismiss) }, - navigatePreview = { navController.popBackStack() }, + navigatePreview = { + if (navController.previousBackStackEntry == null) { + navController.navigateTo(WidgetsRoute.HeadlinesPreview) + } else { + navController.popBackStack() + } + }, modifier = Modifier.widgetSheetPage() ) } @@ -328,6 +354,17 @@ fun WidgetType.toWidgetsPreviewRoute(): WidgetsRoute = when (this) { WidgetType.SUGGESTIONS -> WidgetsRoute.SuggestionsPreview } +fun WidgetType.toWidgetsEditRoute(): WidgetsRoute? = when (this) { + WidgetType.BLOCK -> WidgetsRoute.BlocksEdit + WidgetType.NEWS -> WidgetsRoute.HeadlinesEdit + WidgetType.PRICE -> WidgetsRoute.PriceEdit + WidgetType.WEATHER -> WidgetsRoute.WeatherEdit + WidgetType.CALCULATOR, + WidgetType.FACTS, + WidgetType.SUGGESTIONS, + -> null +} + private fun WidgetsRoute.widgetFlowKey(): WidgetFlowKey? = when (this) { WidgetsRoute.PricePreview, WidgetsRoute.PriceEdit, diff --git a/app/src/main/res/drawable/ic_arrows_out_cardinal.xml b/app/src/main/res/drawable/ic_arrows_out_cardinal.xml new file mode 100644 index 0000000000..978e7ced97 --- /dev/null +++ b/app/src/main/res/drawable/ic_arrows_out_cardinal.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt b/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt new file mode 100644 index 0000000000..de368a1940 --- /dev/null +++ b/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt @@ -0,0 +1,87 @@ +package to.bitkit.models + +import to.bitkit.data.WidgetsData +import to.bitkit.di.json +import kotlin.test.Test +import kotlin.test.assertEquals + +class WidgetSizeTest { + + @Test + fun `default maps wide types`() { + assertEquals(WidgetSize.WIDE, WidgetSize.default(WidgetType.PRICE)) + assertEquals(WidgetSize.WIDE, WidgetSize.default(WidgetType.NEWS)) + assertEquals(WidgetSize.WIDE, WidgetSize.default(WidgetType.SUGGESTIONS)) + } + + @Test + fun `default maps small types`() { + assertEquals(WidgetSize.SMALL, WidgetSize.default(WidgetType.BLOCK)) + assertEquals(WidgetSize.SMALL, WidgetSize.default(WidgetType.FACTS)) + assertEquals(WidgetSize.SMALL, WidgetSize.default(WidgetType.WEATHER)) + assertEquals(WidgetSize.SMALL, WidgetSize.default(WidgetType.CALCULATOR)) + } + + @Test + fun `serializes to ios compatible raw values`() { + assertEquals("\"small\"", json.encodeToString(WidgetSize.SMALL)) + assertEquals("\"wide\"", json.encodeToString(WidgetSize.WIDE)) + } + + @Test + fun `widget with position round trips size`() { + val widget = WidgetWithPosition(type = WidgetType.BLOCK, position = 2, size = WidgetSize.SMALL) + val decoded = json.decodeFromString(json.encodeToString(widget)) + assertEquals(widget, decoded) + } + + @Test + fun `widget without size key defaults to wide for v60 compatibility`() { + val legacy = """{"type":"PRICE","position":0}""" + val decoded = json.decodeFromString(legacy) + assertEquals(WidgetSize.WIDE, decoded.size) + } + + @Test + fun `effective size forces suggestions wide`() { + val suggestions = WidgetWithPosition(type = WidgetType.SUGGESTIONS, position = 0, size = WidgetSize.SMALL) + assertEquals(WidgetSize.WIDE, suggestions.effectiveSize()) + + val block = WidgetWithPosition(type = WidgetType.BLOCK, position = 1, size = WidgetSize.SMALL) + assertEquals(WidgetSize.SMALL, block.effectiveSize()) + } + + @Test + fun `default widget set matches v61 layout`() { + val widgets = WidgetsData().widgets + assertEquals( + listOf( + WidgetType.SUGGESTIONS, + WidgetType.PRICE, + WidgetType.BLOCK, + WidgetType.FACTS, + WidgetType.WEATHER, + WidgetType.CALCULATOR, + WidgetType.NEWS, + ), + widgets.map { it.type }, + ) + widgets.forEachIndexed { index, widget -> + assertEquals(index, widget.position) + assertEquals(WidgetSize.default(widget.type), widget.size) + } + } + + @Test + fun `widgets data round trips through backup payload preserving sizes`() { + val data = WidgetsData( + widgets = listOf( + WidgetWithPosition(WidgetType.PRICE, 0, WidgetSize.WIDE), + WidgetWithPosition(WidgetType.BLOCK, 1, WidgetSize.SMALL), + ), + ) + val payload = WidgetsBackupV1(createdAt = 0L, widgets = data) + val decoded = json.decodeFromString(json.encodeToString(payload)) + assertEquals(data.widgets, decoded.widgets.widgets) + } +} diff --git a/app/src/test/java/to/bitkit/ui/screens/widgets/components/WidgetGridSlotsTest.kt b/app/src/test/java/to/bitkit/ui/screens/widgets/components/WidgetGridSlotsTest.kt new file mode 100644 index 0000000000..dc58b796bc --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/screens/widgets/components/WidgetGridSlotsTest.kt @@ -0,0 +1,105 @@ +package to.bitkit.ui.screens.widgets.components + +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class WidgetGridSlotsTest { + + private val width = 332 + private val spacing = 16 + private val smallHeight = 192 + private val columnWidth = (width - spacing) / 2 // 158 + + private fun heights(vararg values: Int) = values.toList() + + @Test + fun `empty input produces no slots and zero height`() { + val result = widgetGridSlots(emptyList(), width, spacing, emptyList(), smallHeight) + assertEquals(emptyList(), result.slots) + assertEquals(0, result.totalHeight) + } + + @Test + fun `wide widgets stack full width`() { + val result = widgetGridSlots( + isWide = listOf(true, true), + totalWidth = width, + spacingPx = spacing, + heights = heights(100, 120), + smallHeightPx = smallHeight, + ) + assertEquals(WidgetSlot(0, 0, 0, width, 100), result.slots[0]) + assertEquals(WidgetSlot(1, 0, 116, width, 120), result.slots[1]) + assertEquals(236, result.totalHeight) // 100 + 16 + 120 + } + + @Test + fun `consecutive smalls pair side by side`() { + val result = widgetGridSlots( + isWide = listOf(false, false), + totalWidth = width, + spacingPx = spacing, + heights = heights(smallHeight, smallHeight), + smallHeightPx = smallHeight, + ) + assertEquals(WidgetSlot(0, 0, 0, columnWidth, smallHeight), result.slots[0]) + assertEquals(WidgetSlot(1, columnWidth + spacing, 0, columnWidth, smallHeight), result.slots[1]) + assertEquals(smallHeight, result.totalHeight) + } + + @Test + fun `lone trailing small occupies left column`() { + val result = widgetGridSlots( + isWide = listOf(false), + totalWidth = width, + spacingPx = spacing, + heights = heights(smallHeight), + smallHeightPx = smallHeight, + ) + assertEquals(WidgetSlot(0, 0, 0, columnWidth, smallHeight), result.slots.single()) + assertEquals(smallHeight, result.totalHeight) + } + + @Test + fun `mixed wide then paired smalls`() { + val result = widgetGridSlots( + isWide = listOf(true, false, false), + totalWidth = width, + spacingPx = spacing, + heights = heights(100, smallHeight, smallHeight), + smallHeightPx = smallHeight, + ) + assertEquals(WidgetSlot(0, 0, 0, width, 100), result.slots[0]) + assertEquals(WidgetSlot(1, 0, 116, columnWidth, smallHeight), result.slots[1]) + assertEquals(WidgetSlot(2, columnWidth + spacing, 116, columnWidth, smallHeight), result.slots[2]) + assertEquals(308, result.totalHeight) // 100 + 16 + 192 + } + + @Test + fun `nearest slot returns null for empty bounds`() { + assertNull(nearestWidgetSlot(Offset(10f, 10f), emptyMap())) + } + + @Test + fun `nearest slot picks the containing cell`() { + val bounds = mapOf( + 0 to Rect(0f, 0f, 100f, 100f), + 1 to Rect(0f, 116f, 100f, 216f), + ) + assertEquals(0, nearestWidgetSlot(Offset(50f, 50f), bounds)) + assertEquals(1, nearestWidgetSlot(Offset(50f, 150f), bounds)) + } + + @Test + fun `nearest slot biases gap drops to the lower row`() { + val bounds = mapOf( + 0 to Rect(0f, 0f, 100f, 100f), + 1 to Rect(0f, 120f, 100f, 220f), + ) + // A point centred in the gap is equidistant; the lower row wins. + assertEquals(1, nearestWidgetSlot(Offset(50f, 110f), bounds)) + } +} diff --git a/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt b/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt index 32c6d8d1e7..f75f195d0c 100644 --- a/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt +++ b/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.sheets import to.bitkit.models.WidgetType import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNull class WidgetsRouteTest { @@ -16,4 +17,19 @@ class WidgetsRouteTest { assertEquals(WidgetsRoute.WeatherPreview, WidgetType.WEATHER.toWidgetsPreviewRoute()) assertEquals(WidgetsRoute.SuggestionsPreview, WidgetType.SUGGESTIONS.toWidgetsPreviewRoute()) } + + @Test + fun `maps editable widget types to edit routes`() { + assertEquals(WidgetsRoute.BlocksEdit, WidgetType.BLOCK.toWidgetsEditRoute()) + assertEquals(WidgetsRoute.HeadlinesEdit, WidgetType.NEWS.toWidgetsEditRoute()) + assertEquals(WidgetsRoute.PriceEdit, WidgetType.PRICE.toWidgetsEditRoute()) + assertEquals(WidgetsRoute.WeatherEdit, WidgetType.WEATHER.toWidgetsEditRoute()) + } + + @Test + fun `maps non-editable widget types to null edit route`() { + assertNull(WidgetType.CALCULATOR.toWidgetsEditRoute()) + assertNull(WidgetType.FACTS.toWidgetsEditRoute()) + assertNull(WidgetType.SUGGESTIONS.toWidgetsEditRoute()) + } } diff --git a/changelog.d/next/985.added.md b/changelog.d/next/985.added.md new file mode 100644 index 0000000000..1f7ddcc537 --- /dev/null +++ b/changelog.d/next/985.added.md @@ -0,0 +1 @@ +Home widgets can now be resized between compact and wide from the preview sheet, with a redesigned two-column grid, inline edit mode, and an interactive compact calculator. diff --git a/docs/e2e-test-ids.md b/docs/e2e-test-ids.md index 7cd659b13e..2cbea0a29a 100644 --- a/docs/e2e-test-ids.md +++ b/docs/e2e-test-ids.md @@ -667,6 +667,7 @@ Legend: | RN testID | Android testTag | | -------------------------- | ---------------------------- | | HomeScrollView | ✅ | +| CalculatorWidget | ✅ | | PriceWidget | ✅ | | PriceWidgetRow-BTC/EUR | ✅ | | PriceWidgetSource | ✅ |