From 7718296a3b9d8e9497c19893548f5d39e12e7228 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 07:55:18 -0300 Subject: [PATCH 01/33] feat: widget size data layer logic --- .../main/java/to/bitkit/data/WidgetsStore.kt | 38 ++++++++++++++----- .../to/bitkit/models/WidgetWithPosition.kt | 4 ++ 2 files changed, 32 insertions(+), 10 deletions(-) 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/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 From 6ea4127ac6e63935ec348dcfee2018584e8abfa2 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 07:55:48 -0300 Subject: [PATCH 02/33] feat: widget size domain logic --- app/src/main/java/to/bitkit/repositories/WidgetsRepo.kt | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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) } From ba646fb9b2e23f47a70ef5e694841a3d499e52f4 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 07:57:34 -0300 Subject: [PATCH 03/33] feat: re-enabled compact mode and implement size saving logic --- .../widgets/blocks/BlocksPreviewScreen.kt | 8 ++ .../screens/widgets/blocks/BlocksViewModel.kt | 9 +- .../widgets/components/WidgetSizeCarousel.kt | 83 ++++++++++++------- .../widgets/facts/FactsPreviewScreen.kt | 8 ++ .../screens/widgets/facts/FactsViewModel.kt | 9 +- .../headlines/HeadlinesPreviewScreen.kt | 8 ++ .../widgets/headlines/HeadlinesViewModel.kt | 9 +- .../widgets/price/PricePreviewScreen.kt | 8 ++ .../screens/widgets/price/PriceViewModel.kt | 9 +- .../widgets/weather/WeatherPreviewScreen.kt | 8 ++ .../widgets/weather/WeatherViewModel.kt | 9 +- 11 files changed, 134 insertions(+), 34 deletions(-) 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..59bba29111 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 @@ -16,6 +16,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 @@ -42,6 +43,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 +61,8 @@ fun BlocksPreviewScreen( onClickSave = { blocksViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = blocksViewModel::setSize, modifier = modifier ) } @@ -73,6 +77,8 @@ private fun Content( blocksPreferences: BlocksPreferences, block: BlockModel?, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -134,6 +140,8 @@ private fun Content( .testTag("block_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("blocks_preview_carousel") 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/components/WidgetSizeCarousel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt index 743f324914..e96e1b4832 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,43 @@ 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.platform.testTag +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.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) + + LaunchedEffect(pagerState, supportsSmall) { + snapshotFlow { pagerState.currentPage }.collect { page -> + currentOnSizeSelected(pageToSize(page, supportsSmall)) + } + } Column( verticalArrangement = Arrangement.Center, @@ -52,40 +65,39 @@ fun WidgetSizeCarousel( contentAlignment = Alignment.Center, modifier = Modifier.fillMaxWidth() ) { - 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() .testTag("page_indicator") ) { - repeat(PAGE_COUNT) { index -> + repeat(pageCount) { index -> Box( modifier = Modifier .padding(horizontal = 4.dp) @@ -100,3 +112,16 @@ fun WidgetSizeCarousel( } } } + +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 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..019784164b 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 @@ -16,6 +16,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.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton @@ -36,6 +37,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 +53,8 @@ fun FactsPreviewScreen( onClickSave = { factsViewModel.saveWidget(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = factsViewModel::setSize, modifier = modifier ) } @@ -63,6 +67,8 @@ fun FactsPreviewContent( isFactsWidgetEnabled: Boolean, fact: String, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -108,6 +114,8 @@ fun FactsPreviewContent( .testTag("facts_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("facts_preview_carousel") 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/HeadlinesPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt index cc8962c41c..1539030fc6 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 @@ -16,6 +16,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 @@ -42,6 +43,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 +61,8 @@ fun HeadlinesPreviewScreen( onClickSave = { headlinesViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = headlinesViewModel::setSize, modifier = modifier ) } @@ -73,6 +77,8 @@ fun HeadlinesPreviewContent( headlinePreferences: HeadlinePreferences, article: ArticleModel, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.WIDE, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -139,6 +145,8 @@ fun HeadlinesPreviewContent( .testTag("headline_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("headlines_preview_carousel") 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/PricePreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt index b884a9f332..f696910a4b 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) 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/WeatherPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt index 0d8f29f447..3c1a16145f 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 @@ -17,6 +17,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 @@ -44,6 +45,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 +63,8 @@ fun WeatherPreviewScreen( onClickSave = { weatherViewModel.savePreferences(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = weatherViewModel::setSize, modifier = modifier ) } @@ -75,6 +79,8 @@ fun WeatherPreviewContent( weatherPreferences: WeatherPreferences, weatherModel: WeatherModel?, modifier: Modifier = Modifier, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -136,6 +142,8 @@ fun WeatherPreviewContent( .testTag("weather_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("weather_preview_carousel") 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() } } From eabc02e501b070af5b063b44578d73ecc735bc5f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 08:18:59 -0300 Subject: [PATCH 04/33] feat: calculator compact mode --- .../calculator/CalculatorPreviewScreen.kt | 8 ++ .../widgets/calculator/CalculatorViewModel.kt | 12 ++- .../calculator/components/CalculatorCard.kt | 20 +++- .../components/CalculatorNumberPadBar.kt | 94 +++++++++++++++++++ 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/calculator/components/CalculatorNumberPadBar.kt 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..3250ba0ada 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 @@ -18,6 +18,7 @@ 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 @@ -40,6 +41,7 @@ fun CalculatorPreviewScreen( ) { val isCalculatorWidgetEnabled by viewModel.isCalculatorWidgetEnabled.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val draftSize by viewModel.draftSize.collectAsStateWithLifecycle() CalculatorPreviewContent( onBack = onBack, @@ -55,6 +57,8 @@ fun CalculatorPreviewScreen( onClickSave = { viewModel.saveWidget(onComplete = onClose) }, + initialSize = draftSize, + onSizeSelected = viewModel::setSize, modifier = modifier ) } @@ -71,6 +75,8 @@ fun CalculatorPreviewContent( onFiatChange: (String) -> Unit = {}, onInputSelected: (MoneyType) -> Unit = {}, onInputDismissed: () -> Unit = {}, + initialSize: WidgetSize = WidgetSize.SMALL, + onSizeSelected: (WidgetSize) -> Unit = {}, ) { Column( modifier = modifier @@ -130,6 +136,8 @@ fun CalculatorPreviewContent( .testTag("calculator_card_wide") ) }, + initialSize = initialSize, + onSizeSelected = onSizeSelected, modifier = Modifier .fillMaxWidth() .testTag("calculator_preview_carousel") 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..f97997fa06 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 @@ -6,6 +6,8 @@ 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.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -118,7 +120,7 @@ fun CalculatorCardEditor( } Column(modifier = modifier) { - Content( + CalculatorEditableRows( btcPrimaryDisplayUnit = btcPrimaryDisplayUnit, btcValue = btcValue, fiatSymbol = fiatSymbol, @@ -342,7 +344,7 @@ private fun ColumnScope.Numpad( } } -private fun currentInputValue( +internal fun currentInputValue( input: MoneyType, btcValue: String, fiatValue: String, @@ -351,7 +353,7 @@ private fun currentInputValue( MoneyType.FIAT -> fiatValue } -private fun nextInputValue( +internal fun nextInputValue( input: MoneyType, key: String, btcValue: String, @@ -381,7 +383,7 @@ private fun nextInputValue( } @Composable -private fun Content( +fun CalculatorEditableRows( modifier: Modifier = Modifier, btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, @@ -439,6 +441,8 @@ fun CalculatorCardSmall( fiatSymbol: String, fiatValue: String, modifier: Modifier = Modifier, + activeInput: MoneyType? = null, + onSelectInput: ((MoneyType) -> Unit)? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), @@ -454,6 +458,8 @@ fun CalculatorCardSmall( value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), iconSize = 24.dp, rowPadding = 12.dp, + isActive = activeInput == MoneyType.BITCOIN, + onClick = onSelectInput?.let { { it(MoneyType.BITCOIN) } }, modifier = Modifier .fillMaxWidth() .testTag("CalculatorSmallBtcRow") @@ -463,6 +469,8 @@ fun CalculatorCardSmall( value = formatFiatValue(fiatValue), iconSize = 24.dp, rowPadding = 12.dp, + isActive = activeInput == MoneyType.FIAT, + onClick = onSelectInput?.let { { it(MoneyType.FIAT) } }, modifier = Modifier .fillMaxWidth() .testTag("CalculatorSmallFiatRow") @@ -477,6 +485,8 @@ private fun ReadOnlyRow( iconSize: Dp, rowPadding: Dp, modifier: Modifier = Modifier, + isActive: Boolean = false, + onClick: (() -> Unit)? = null, ) { val displayCurrencySymbol = currencySymbol.toCalculatorDisplaySymbol() @@ -486,6 +496,8 @@ private fun ReadOnlyRow( modifier = modifier .clip(MaterialTheme.shapes.small) .background(Colors.Black) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) + .then(if (isActive) Modifier.border(1.dp, Colors.Brand, MaterialTheme.shapes.small) else Modifier) .padding(rowPadding) ) { Box( 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)) + } +} From 760a102ac7b5340294154895ffa7eaa353e9ca84 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 08:41:58 -0300 Subject: [PATCH 05/33] feat: data layer --- .../main/java/to/bitkit/models/WidgetSize.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 app/src/main/java/to/bitkit/models/WidgetSize.kt 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 + } + } +} From 6e93d8c554b75f1937c15b59e8ff57a5c339b076 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 09:11:28 -0300 Subject: [PATCH 06/33] feat: widgets grid --- .../bitkit/ui/screens/wallets/HomeScreen.kt | 407 +++++++++++------- .../ui/screens/widgets/DragAndDropWidget.kt | 115 ----- .../ui/screens/widgets/DragDropColumn.kt | 92 ---- .../ui/screens/widgets/WidgetSizeDraft.kt | 44 ++ .../widgets/components/EditWidgetOverlay.kt | 191 ++++++++ .../widgets/components/EditableWidgetGrid.kt | 235 ++++++++++ .../screens/widgets/components/WidgetGrid.kt | 155 +++++++ .../res/drawable/ic_arrows_out_cardinal.xml | 9 + .../java/to/bitkit/models/WidgetSizeTest.kt | 89 ++++ .../widgets/components/WidgetGridSlotsTest.kt | 105 +++++ changelog.d/next/965.added.md | 1 + 11 files changed, 1080 insertions(+), 363 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/DragAndDropWidget.kt delete mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/DragDropColumn.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/WidgetSizeDraft.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt create mode 100644 app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt create mode 100644 app/src/main/res/drawable/ic_arrows_out_cardinal.xml create mode 100644 app/src/test/java/to/bitkit/models/WidgetSizeTest.kt create mode 100644 app/src/test/java/to/bitkit/ui/screens/widgets/components/WidgetGridSlotsTest.kt create mode 100644 changelog.d/next/965.added.md 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..a7f9211a67 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 @@ -3,7 +3,6 @@ package to.bitkit.ui.screens.wallets import androidx.compose.animation.AnimatedVisibility 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 +39,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 @@ -110,9 +110,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,15 +148,25 @@ 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.components.CalculatorCard +import to.bitkit.ui.screens.widgets.calculator.CalculatorUiState +import to.bitkit.ui.screens.widgets.calculator.CalculatorViewModel +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorEditableRows +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 @@ -514,7 +527,6 @@ private fun Content( 1 -> WidgetsPage( homeUiState = homeUiState, calculatorInputDismissKey = calculatorInputDismissKey, - isCalculatorInputActive = isCalculatorInputActive, onDismissCalculatorInput = ::dismissKeyboard, onCalculatorInputActiveChanged = onCalculatorInputActiveChange, onRemoveSuggestion = onRemoveSuggestion, @@ -681,12 +693,11 @@ private fun BalancesSection( } } -@Suppress("CyclomaticComplexMethod", "MagicNumber") +@Suppress("CyclomaticComplexMethod", "MagicNumber", "LongMethod") @Composable private fun WidgetsPage( homeUiState: HomeUiState, calculatorInputDismissKey: Int, - isCalculatorInputActive: Boolean, onDismissCalculatorInput: () -> Unit, onCalculatorInputActiveChanged: (Boolean) -> Unit, onRemoveSuggestion: (Suggestion) -> Unit, @@ -695,58 +706,50 @@ private fun WidgetsPage( onClickEditWidget: (WidgetType) -> Unit, onClickDeleteWidget: (WidgetType) -> Unit, onMoveWidget: (Int, Int) -> Unit, + calculatorViewModel: CalculatorViewModel = hiltViewModel(), ) { + val calcState by calculatorViewModel.uiState.collectAsStateWithLifecycle() + 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 latestNumberPadBounds by rememberUpdatedState(numberPadBounds) - val isCalculatorFirst = homeUiState.widgetsWithPosition.firstOrNull()?.type == WidgetType.CALCULATOR + // 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) calculatorViewModel.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) { + calculatorViewModel.onInputDismissed() calculatorBounds = null numberPadBounds = null - firstCalculatorTopPaddingTarget = 0.dp } } - LaunchedEffect(isCalculatorInputActive, numberPadBounds != null, isCalculatorFirst) { - if (!isCalculatorInputActive || numberPadBounds == null) { - firstCalculatorTopPaddingTarget = 0.dp - return@LaunchedEffect - } - withFrameNanos { - // Wait for the focused calculator and number pad bounds to settle before measuring. - } - + // Scroll so the focused calculator sits just above the number pad bar. + LaunchedEffect(isCalcActive, numberPadBounds != null) { + if (!isCalcActive || numberPadBounds == null) return@LaunchedEffect + withFrameNanos { } val page = latestPageBounds ?: 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) } @@ -757,9 +760,10 @@ private fun WidgetsPage( .fillMaxSize() .onGloballyPositioned { pageBounds = it.boundsInRoot() } .dismissCalculatorInputOnOutsideTap( - isCalculatorInputActive = isCalculatorInputActive, + isCalculatorInputActive = isCalcActive, pageBounds = pageBounds, calculatorBounds = calculatorBounds, + numberPadBounds = numberPadBounds, onDismiss = onDismissCalculatorInput, ) ) { @@ -769,7 +773,7 @@ private fun WidgetsPage( .fillMaxSize() .verticalScroll( state = widgetsScrollState, - enabled = !isCalculatorInputActive, + enabled = !isCalcActive, ) ) { StatusBarSpacer() @@ -777,45 +781,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 }, modifier = Modifier.fillMaxWidth() - ) + ) { + visibleWidgets.forEach { widget -> + WidgetCardContent( + widget = widget, + homeUiState = homeUiState, + calcState = calcState, + onSelectCalcInput = calculatorViewModel::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), @@ -828,7 +839,26 @@ private fun WidgetsPage( .testTag("WidgetsAdd") ) - VerticalSpacer(150.dp + imeBottomPadding) + val calcReserve = if (isCalcActive) { + numberPadBounds?.height?.let { with(density) { it.toDp() } } ?: 320.dp + } else { + 0.dp + } + VerticalSpacer(150.dp + imeBottomPadding + calcReserve) + } + + calcState.activeInput?.let { activeInput -> + CalculatorNumberPadBar( + activeInput = activeInput, + btcValue = calcState.btcValue, + fiatValue = calcState.fiatValue, + btcPrimaryDisplayUnit = calcState.displayUnit, + onBtcChange = calculatorViewModel::onBtcInputChanged, + onFiatChange = calculatorViewModel::onFiatInputChanged, + modifier = Modifier + .align(Alignment.BottomCenter) + .onGloballyPositioned { numberPadBounds = it.boundsInRoot() } + ) } } } @@ -847,8 +877,9 @@ 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 +898,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 +975,180 @@ 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()) } + if (small) { + CalculatorCardSmall( + btcPrimaryDisplayUnit = calcState.displayUnit, + btcValue = calcState.btcValue, + fiatSymbol = calcState.currencySymbol, + fiatValue = calcState.fiatValue, + activeInput = calcState.activeInput, + onSelectInput = onSelectCalcInput, + modifier = calcModifier + ) + } else { + CalculatorEditableRows( + 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/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/components/EditWidgetOverlay.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt new file mode 100644 index 0000000000..851b1eac61 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt @@ -0,0 +1,191 @@ +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.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, WidgetType.FACTS, WidgetType.CALCULATOR) + +/** + * 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, + 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..1c7320c550 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt @@ -0,0 +1,235 @@ +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.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import kotlinx.collections.immutable.ImmutableList +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 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) } + val latestItems by rememberUpdatedState(items) + + val isWide = items.map { it.effectiveSize() == WidgetSize.WIDE } + + 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 + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + }, + onDrag = { delta -> + dragPointer += delta + val target = nearestWidgetSlot(dragPointer, cellBounds) + if (target != null && target != draggedType) { + val from = latestItems.indexOfFirst { it.type == draggedType } + val to = latestItems.indexOfFirst { it.type == target } + if (from >= 0 && to >= 0) { + onMove(from, to) + haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + } + } + }, + onEnd = { draggedType = 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() }, + ) + ) { + 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) + 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..37f3579b00 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt @@ -0,0 +1,155 @@ +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 + +/** 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 h = heights.getOrElse(i) { smallHeightPx } + slots.add(WidgetSlot(index = i, x = 0, y = y, width = totalWidth, height = h)) + y += h + spacingPx + i += 1 + } + + i + 1 < isWide.size && !isWide[i + 1] -> { + val rowHeight = maxOf( + heights.getOrElse(i) { smallHeightPx }, + heights.getOrElse(i + 1) { smallHeightPx }, + ) + slots.add(WidgetSlot(index = i, x = 0, y = y, width = columnWidth, height = rowHeight)) + slots.add( + WidgetSlot( + index = i + 1, + x = columnWidth + spacingPx, + y = y, + width = columnWidth, + height = rowHeight, + ) + ) + y += rowHeight + spacingPx + i += 2 + } + + else -> { + val h = heights.getOrElse(i) { smallHeightPx } + slots.add(WidgetSlot(index = i, x = 0, y = y, width = columnWidth, height = h)) + y += h + spacingPx + i += 1 + } + } + } + + return WidgetGridResult(slots = slots, totalHeight = maxOf(0, y - spacingPx)) +} + +/** + * 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: List, + 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/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..8ff1e261ab --- /dev/null +++ b/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt @@ -0,0 +1,89 @@ +package to.bitkit.models + +import to.bitkit.data.WidgetsData +import to.bitkit.di.json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +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) + assertTrue(json.encodeToString(payload).contains("\"size\": \"small\"")) + } +} 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/changelog.d/next/965.added.md b/changelog.d/next/965.added.md new file mode 100644 index 0000000000..1f7ddcc537 --- /dev/null +++ b/changelog.d/next/965.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. From 4e7baa4e8d2fdb222bfb617a40ff03ac1451d8c0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 09:20:32 -0300 Subject: [PATCH 07/33] fix: add a guard to reorder only when target slot changes --- .../widgets/components/EditableWidgetGrid.kt | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) 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 index 1c7320c550..da6511e043 100644 --- 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 @@ -90,6 +90,8 @@ fun EditableWidgetGrid( 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 } @@ -107,21 +109,33 @@ fun EditableWidgetGrid( 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) - if (target != null && target != draggedType) { - val from = latestItems.indexOfFirst { it.type == draggedType } - val to = latestItems.indexOfFirst { it.type == target } - if (from >= 0 && to >= 0) { - onMove(from, to) - haptic.performHapticFeedback(HapticFeedbackType.SegmentTick) + 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 }, + onEnd = { + draggedType = null + lastTarget = null + }, ) } From 5f577a96f73e7ea16a19de0b7792f662e8f5e466 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 09:27:39 -0300 Subject: [PATCH 08/33] fix: number pad overlap calculator --- .../bitkit/ui/screens/wallets/HomeScreen.kt | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) 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 a7f9211a67..64bdf2e377 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 @@ -717,8 +717,9 @@ private fun WidgetsPage( var pageBounds by remember { mutableStateOf(null) } var calculatorBounds by remember { mutableStateOf(null) } var numberPadBounds by remember { mutableStateOf(null) } - val latestPageBounds by rememberUpdatedState(pageBounds) + val latestCalculatorBounds by rememberUpdatedState(calculatorBounds) val latestNumberPadBounds by rememberUpdatedState(numberPadBounds) + val revealMarginPx = with(density) { 16.dp.toPx() } // Keep the hoisted state in sync so the pager and page-change handling react to calculator input. LaunchedEffect(isCalcActive) { onCalculatorInputActiveChanged(isCalcActive) } @@ -738,17 +739,18 @@ private fun WidgetsPage( } } - // Scroll so the focused calculator sits just above the number pad bar. + // Scroll so the focused calculator card sits just above the number pad bar (any position). LaunchedEffect(isCalcActive, numberPadBounds != null) { if (!isCalcActive || numberPadBounds == null) return@LaunchedEffect withFrameNanos { } - val page = latestPageBounds ?: return@LaunchedEffect + val calculator = latestCalculatorBounds ?: return@LaunchedEffect val numberPad = latestNumberPadBounds ?: return@LaunchedEffect val targetScroll = calculatorRevealScrollTarget( currentScroll = widgetsScrollState.value, maxScroll = widgetsScrollState.maxValue, - pageBounds = page, + calculatorBounds = calculator, numberPadBounds = numberPad, + marginPx = revealMarginPx, ) if (targetScroll != widgetsScrollState.value) { widgetsScrollState.animateScrollTo(targetScroll) @@ -866,11 +868,14 @@ private fun WidgetsPage( private fun calculatorRevealScrollTarget( currentScroll: Int, maxScroll: Int, - pageBounds: Rect, + calculatorBounds: Rect, numberPadBounds: Rect, + marginPx: Float, ): Int { - val delta = numberPadBounds.bottom - pageBounds.bottom - return (currentScroll + delta.roundToInt()).coerceIn(0, maxScroll) + // Lift the page so the calculator card's bottom clears the top of the number pad bar. + val overlap = calculatorBounds.bottom - (numberPadBounds.top - marginPx) + if (overlap <= 0f) return currentScroll + return (currentScroll + overlap.roundToInt()).coerceIn(0, maxScroll) } private fun Modifier.dismissCalculatorInputOnOutsideTap( From 00d92169f1ef766b98dbaf87072b8599016df82e Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 09:30:22 -0300 Subject: [PATCH 09/33] chore: rename changelog fragment --- changelog.d/next/{965.added.md => 985.added.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog.d/next/{965.added.md => 985.added.md} (100%) diff --git a/changelog.d/next/965.added.md b/changelog.d/next/985.added.md similarity index 100% rename from changelog.d/next/965.added.md rename to changelog.d/next/985.added.md From f8ee4b6fd44d8033eb72fbd6bbdb4b1669e03bf9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 10:03:37 -0300 Subject: [PATCH 10/33] fix: animate calculator to previous position on number pad dismiss --- .../bitkit/ui/screens/wallets/HomeScreen.kt | 50 +++++++------------ 1 file changed, 18 insertions(+), 32 deletions(-) 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 64bdf2e377..777284afc1 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,6 +1,7 @@ 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.exponentialDecay @@ -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 @@ -181,11 +183,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") @@ -720,6 +725,8 @@ private fun WidgetsPage( val latestCalculatorBounds by rememberUpdatedState(calculatorBounds) val latestNumberPadBounds by rememberUpdatedState(numberPadBounds) 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) } @@ -739,22 +746,18 @@ private fun WidgetsPage( } } - // Scroll so the focused calculator card sits just above the number pad bar (any position). + // 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) return@LaunchedEffect + if (!isCalcActive || numberPadBounds == null) { + focusedOffsetY.animateTo(0f, CALCULATOR_LIFT_SPEC) + return@LaunchedEffect + } withFrameNanos { } val calculator = latestCalculatorBounds ?: return@LaunchedEffect val numberPad = latestNumberPadBounds ?: return@LaunchedEffect - val targetScroll = calculatorRevealScrollTarget( - currentScroll = widgetsScrollState.value, - maxScroll = widgetsScrollState.maxValue, - calculatorBounds = calculator, - numberPadBounds = numberPad, - marginPx = revealMarginPx, - ) - 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( @@ -773,6 +776,7 @@ private fun WidgetsPage( modifier = Modifier .padding(horizontal = 16.dp) .fillMaxSize() + .graphicsLayer { translationY = focusedOffsetY.value } .verticalScroll( state = widgetsScrollState, enabled = !isCalcActive, @@ -841,12 +845,7 @@ private fun WidgetsPage( .testTag("WidgetsAdd") ) - val calcReserve = if (isCalcActive) { - numberPadBounds?.height?.let { with(density) { it.toDp() } } ?: 320.dp - } else { - 0.dp - } - VerticalSpacer(150.dp + imeBottomPadding + calcReserve) + VerticalSpacer(150.dp + imeBottomPadding) } calcState.activeInput?.let { activeInput -> @@ -865,19 +864,6 @@ private fun WidgetsPage( } } -private fun calculatorRevealScrollTarget( - currentScroll: Int, - maxScroll: Int, - calculatorBounds: Rect, - numberPadBounds: Rect, - marginPx: Float, -): Int { - // Lift the page so the calculator card's bottom clears the top of the number pad bar. - val overlap = calculatorBounds.bottom - (numberPadBounds.top - marginPx) - if (overlap <= 0f) return currentScroll - return (currentScroll + overlap.roundToInt()).coerceIn(0, maxScroll) -} - private fun Modifier.dismissCalculatorInputOnOutsideTap( isCalculatorInputActive: Boolean, pageBounds: Rect?, From 7291cb6adc79c85630371a348cc40f5bd3067ea5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 10:45:16 -0300 Subject: [PATCH 11/33] fix: remove active orange border --- .../widgets/calculator/components/CalculatorCard.kt | 11 ++++------- .../widgets/calculator/components/CalculatorInput.kt | 2 +- 2 files changed, 5 insertions(+), 8 deletions(-) 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 f97997fa06..c51f73bbfb 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 @@ -6,7 +6,6 @@ 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.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -38,7 +37,6 @@ 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 @@ -497,7 +495,6 @@ private fun ReadOnlyRow( .clip(MaterialTheme.shapes.small) .background(Colors.Black) .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .then(if (isActive) Modifier.border(1.dp, Colors.Brand, MaterialTheme.shapes.small) else Modifier) .padding(rowPadding) ) { Box( @@ -513,10 +510,10 @@ private fun ReadOnlyRow( maxLines = 1, ) } - BodyMSB( - text = value, - maxLines = 1, - overflow = TextOverflow.Ellipsis, + InputValue( + value = value, + placeholder = "", + isActive = isActive, modifier = Modifier.weight(1f) ) } 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..1f44f505cb 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 @@ -96,7 +96,7 @@ fun CalculatorInput( } @Composable -private fun InputValue( +internal fun InputValue( value: String, placeholder: String, isActive: Boolean, From 37f5141f5abd91c18a186afc020552b6e31a1e99 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 10:54:18 -0300 Subject: [PATCH 12/33] fix: input corner radius --- .../ui/screens/widgets/calculator/components/CalculatorCard.kt | 2 +- .../screens/widgets/calculator/components/CalculatorInput.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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 c51f73bbfb..3591912722 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 @@ -492,7 +492,7 @@ 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) 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 1f44f505cb..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 From a2e9000fe9aa420c2dff73c7272d83b4731596e8 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 11:00:54 -0300 Subject: [PATCH 13/33] fix: make calculator preview read-only --- .../calculator/CalculatorPreviewScreen.kt | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) 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 3250ba0ada..e8d7d6bfa8 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 @@ -17,15 +17,14 @@ 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.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorEditableRows import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -47,10 +46,6 @@ fun CalculatorPreviewScreen( onBack = onBack, isCalculatorWidgetEnabled = isCalculatorWidgetEnabled, uiState = uiState, - onBtcChange = viewModel::onBtcInputChanged, - onFiatChange = viewModel::onFiatInputChanged, - onInputSelected = viewModel::onInputSelected, - onInputDismissed = viewModel::onInputDismissed, onClickDelete = { viewModel.removeWidget(onComplete = onClose) }, @@ -71,10 +66,6 @@ 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 = {}, ) { @@ -121,16 +112,12 @@ fun CalculatorPreviewContent( ) }, wideContent = { - CalculatorCardEditor( + CalculatorEditableRows( 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") From 6bee6897bf3eaf2b536434589a7331fa64649e01 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 11:10:51 -0300 Subject: [PATCH 14/33] fix: display cached calculator value on widget gallery --- .../to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt | 12 +++++++----- .../ui/screens/widgets/WidgetsGalleryViewModel.kt | 9 +++++++++ .../main/java/to/bitkit/ui/sheets/WidgetsSheet.kt | 2 ++ 3 files changed, 18 insertions(+), 5 deletions(-) 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..3a92814edd 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), @@ -232,10 +236,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 +492,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/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/sheets/WidgetsSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt index 49e3576cd5..c1ee9b82be 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 ) } From 6a4c30030eaf14e650e9b5a157dcf87cecd8ea93 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 13:47:54 -0300 Subject: [PATCH 15/33] fix: drop first emission with default value --- .../widgets/components/WidgetSizeCarousel.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 e96e1b4832..11c2095094 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 @@ -22,6 +22,7 @@ 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 kotlinx.coroutines.flow.drop import to.bitkit.R import to.bitkit.models.WidgetSize import to.bitkit.ui.components.Caption13Up @@ -44,12 +45,26 @@ fun WidgetSizeCarousel( ) val currentOnSizeSelected by rememberUpdatedState(onSizeSelected) - LaunchedEffect(pagerState, supportsSmall) { - snapshotFlow { pagerState.currentPage }.collect { page -> - currentOnSizeSelected(pageToSize(page, supportsSmall)) + // 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, modifier = modifier.testTag("widget_size_carousel") From 2805dc92b0ae1fcd77132931c72dd2e8e2466cc9 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 14:10:48 -0300 Subject: [PATCH 16/33] refactor: remove dead code and component renaming --- .../CalculatorCardIntegrationTest.kt | 335 ------------------ .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +- .../calculator/CalculatorPreviewScreen.kt | 4 +- .../calculator/components/CalculatorCard.kt | 326 +---------------- 4 files changed, 6 insertions(+), 663 deletions(-) delete mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt 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/CalculatorCardIntegrationTest.kt deleted file mode 100644 index 3223e10e94..0000000000 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt +++ /dev/null @@ -1,335 +0,0 @@ -package to.bitkit.ui.screens.widgets.calculator - -import androidx.compose.foundation.layout.fillMaxWidth -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.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.ViewModelStore -import androidx.test.ext.junit.runners.AndroidJUnit4 -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.android.testing.HiltAndroidRule -import dagger.hilt.android.testing.HiltAndroidTest -import dagger.hilt.android.testing.UninstallModules -import dagger.hilt.components.SingletonComponent -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking -import org.junit.After -import org.junit.Before -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import to.bitkit.data.AppCacheData -import to.bitkit.data.CacheStore -import to.bitkit.data.SettingsData -import to.bitkit.data.SettingsStore -import to.bitkit.data.WidgetsData -import to.bitkit.data.WidgetsStore -import to.bitkit.di.RepoModule -import to.bitkit.models.BitcoinDisplayUnit -import to.bitkit.models.FxRate -import to.bitkit.models.USD -import to.bitkit.models.WidgetType -import to.bitkit.models.WidgetWithPosition -import to.bitkit.models.WidgetsBackupV1 -import to.bitkit.models.widget.CalculatorValues -import to.bitkit.repositories.AmountInputHandler -import to.bitkit.repositories.CurrencyRepo -import to.bitkit.repositories.WidgetsRepo -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.theme.AppThemeSurface -import java.util.Locale -import javax.inject.Inject -import javax.inject.Named -import kotlin.test.assertEquals - -@HiltAndroidTest -@UninstallModules(RepoModule::class) -@RunWith(AndroidJUnit4::class) -@CalculatorWidget -@DeviceIntegration -@DeviceUiIntegration -class CalculatorCardIntegrationTest { - - @get:Rule - val hiltRule = HiltAndroidRule(this) - - @get:Rule - val composeTestRule = createComposeRule() - - @Inject - lateinit var widgetsRepo: WidgetsRepo - - @Inject - lateinit var currencyRepo: CurrencyRepo - - @Inject - lateinit var widgetsStore: WidgetsStore - - @Inject - lateinit var settingsStore: SettingsStore - - @Inject - lateinit var cacheStore: CacheStore - - private lateinit var viewModelStore: ViewModelStore - private lateinit var calculatorViewModel: CalculatorViewModel - private lateinit var previousWidgetsData: WidgetsData - private lateinit var previousSettingsData: SettingsData - private lateinit var previousCacheData: AppCacheData - private lateinit var previousLocale: Locale - - @Before - fun setUp() { - previousLocale = Locale.getDefault() - Locale.setDefault(Locale.US) - hiltRule.inject() - viewModelStore = ViewModelStore() - - runBlocking { - previousWidgetsData = widgetsStore.data.first() - previousSettingsData = settingsStore.data.first() - previousCacheData = cacheStore.data.first() - - settingsStore.update { - it.copy( - selectedCurrency = USD, - displayUnit = BitcoinDisplayUnit.MODERN, - ) - } - cacheStore.update { it.copy(cachedRates = listOf(testUsdRate)) } - widgetsStore.restoreFromBackup( - WidgetsBackupV1( - createdAt = TEST_CREATED_AT, - widgets = WidgetsData( - widgets = listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)), - calculatorValues = emptyCalculatorValues, - ), - ) - ).getOrThrow() - - currencyRepo.currencyState.first { - it.selectedCurrency == USD && - it.displayUnit == BitcoinDisplayUnit.MODERN && - it.rates.any { rate -> rate.quote == USD && rate.lastPrice == TEST_USD_RATE } - } - widgetsRepo.widgetsDataFlow.first { - it.widgets == listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)) && - it.calculatorValues == emptyCalculatorValues - } - } - - calculatorViewModel = createCalculatorViewModel() - } - - @After - fun tearDown() { - if (::viewModelStore.isInitialized) { - viewModelStore.clear() - } - runBlocking { - widgetsStore.restoreFromBackup( - WidgetsBackupV1( - createdAt = TEST_CREATED_AT, - widgets = previousWidgetsData, - ) - ).getOrThrow() - settingsStore.update { previousSettingsData } - cacheStore.update { previousCacheData } - } - Locale.setDefault(previousLocale) - } - - @Test - fun btcInputUpdatesFiatValueAndPersistsWidgetState() { - setCalculatorCard() - - replaceInput(BTC_INPUT_INDEX, "12340") - - waitForValues( - btcValue = "12340", - fiatValue = "12.34", - ) - - assertInputText(BTC_INPUT_INDEX, "12 340") - assertInputText(FIAT_INPUT_INDEX, "12.34") - assertPersistedValues( - btcValue = "12340", - fiatValue = "12.34", - ) - } - - @Test - fun fiatInputUpdatesBtcValueAndPersistsWidgetState() { - setCalculatorCard() - - replaceInput(FIAT_INPUT_INDEX, "10.00") - - waitForValues( - btcValue = "10000", - fiatValue = "10.00", - ) - - assertInputText(BTC_INPUT_INDEX, "10 000") - assertInputText(FIAT_INPUT_INDEX, "10.00") - assertPersistedValues( - btcValue = "10000", - fiatValue = "10.00", - ) - } - - private fun createCalculatorViewModel(): CalculatorViewModel { - return ViewModelProvider( - viewModelStore, - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return CalculatorViewModel( - widgetsRepo = widgetsRepo, - currencyRepo = currencyRepo, - ) as T - } - }, - )[CalculatorViewModel::class.java] - } - - private fun setCalculatorCard() { - composeTestRule.setContent { - AppThemeSurface { - CalculatorCard( - calculatorViewModel = calculatorViewModel, - modifier = Modifier.fillMaxWidth() - ) - } - } - composeTestRule.waitForIdle() - composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - composeTestRule.onAllNodes(hasSetTextAction()).fetchSemanticsNodes().size == INPUT_COUNT - } - } - - 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 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, - ) - } - - 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, - ) - } - } - - 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, - ) { - assertEquals( - CalculatorValues( - btcValue = btcValue, - fiatValue = fiatValue, - satsValue = btcValue.toLong(), - displayUnit = BitcoinDisplayUnit.MODERN, - ), - widgetsRepo.widgetsDataFlow.value.calculatorValues, - ) - } - - 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 TIMEOUT_MS = 5_000L - private const val TEST_CREATED_AT = 0L - private const val TEST_USD_RATE = "100000" - - private val emptyCalculatorValues = CalculatorValues( - btcValue = "", - fiatValue = "", - ) - - private val testUsdRate = FxRate( - symbol = "BTCUSD", - lastPrice = TEST_USD_RATE, - base = "BTC", - baseName = "Bitcoin", - quote = USD, - quoteName = "US Dollar", - currencySymbol = "$", - currencyFlag = "US", - lastUpdatedAt = TEST_CREATED_AT, - ) - } - - @Module - @InstallIn(SingletonComponent::class) - object TestRepoModule { - - @Provides - fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo - - @Provides - @Named("enablePolling") - fun provideEnablePolling(): Boolean = false - } -} 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 777284afc1..a3f39298f9 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 @@ -155,8 +155,8 @@ 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.CalculatorEditableRows 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 @@ -1098,7 +1098,7 @@ private fun WidgetCardContent( modifier = calcModifier ) } else { - CalculatorEditableRows( + CalculatorCard( btcPrimaryDisplayUnit = calcState.displayUnit, btcValue = calcState.btcValue, fiatSymbol = calcState.currencySymbol, 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 e8d7d6bfa8..94ed44777a 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 @@ -23,8 +23,8 @@ 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.CalculatorCard import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall -import to.bitkit.ui.screens.widgets.calculator.components.CalculatorEditableRows import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.screens.widgets.components.widgetSheetContent import to.bitkit.ui.theme.AppThemeSurface @@ -112,7 +112,7 @@ fun CalculatorPreviewContent( ) }, wideContent = { - CalculatorEditableRows( + CalculatorCard( btcPrimaryDisplayUnit = uiState.displayUnit, btcValue = uiState.btcValue, fiatSymbol = uiState.currencySymbol, 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 3591912722..4a087fc674 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,16 +1,10 @@ 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.padding @@ -18,46 +12,24 @@ 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.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,281 +38,6 @@ 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) { - CalculatorEditableRows( - 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)) - } - } - } -} internal fun currentInputValue( input: MoneyType, @@ -381,7 +78,7 @@ internal fun nextInputValue( } @Composable -fun CalculatorEditableRows( +fun CalculatorCard( modifier: Modifier = Modifier, btcPrimaryDisplayUnit: BitcoinDisplayUnit, btcValue: String, @@ -430,8 +127,6 @@ fun CalculatorEditableRows( } } -private val ERROR_DELAY = 500.milliseconds - @Composable fun CalculatorCardSmall( btcPrimaryDisplayUnit: BitcoinDisplayUnit, @@ -528,32 +223,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", From a1a405e28272358092ab01b2fd3275cc1fb0345d Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 14:22:32 -0300 Subject: [PATCH 17/33] test: calculator widget instrumented test --- .../calculator/CalculatorWidgetInputTest.kt | 313 ++++++++++++++++++ 1 file changed, 313 insertions(+) create mode 100644 app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt new file mode 100644 index 0000000000..1492031ea5 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorWidgetInputTest.kt @@ -0,0 +1,313 @@ +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.junit4.createComposeRule +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 +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import dagger.hilt.android.testing.UninstallModules +import dagger.hilt.components.SingletonComponent +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.data.AppCacheData +import to.bitkit.data.CacheStore +import to.bitkit.data.SettingsData +import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsData +import to.bitkit.data.WidgetsStore +import to.bitkit.di.RepoModule +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.FxRate +import to.bitkit.models.USD +import to.bitkit.models.WidgetType +import to.bitkit.models.WidgetWithPosition +import to.bitkit.models.WidgetsBackupV1 +import to.bitkit.models.widget.CalculatorValues +import to.bitkit.repositories.AmountInputHandler +import to.bitkit.repositories.CurrencyRepo +import to.bitkit.repositories.WidgetsRepo +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 +import javax.inject.Named +import kotlin.test.assertEquals + +@HiltAndroidTest +@UninstallModules(RepoModule::class) +@RunWith(AndroidJUnit4::class) +@CalculatorWidget +@DeviceIntegration +@DeviceUiIntegration +class CalculatorWidgetInputTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Inject + lateinit var widgetsRepo: WidgetsRepo + + @Inject + lateinit var currencyRepo: CurrencyRepo + + @Inject + lateinit var widgetsStore: WidgetsStore + + @Inject + lateinit var settingsStore: SettingsStore + + @Inject + lateinit var cacheStore: CacheStore + + private lateinit var viewModelStore: ViewModelStore + private lateinit var calculatorViewModel: CalculatorViewModel + private lateinit var previousWidgetsData: WidgetsData + private lateinit var previousSettingsData: SettingsData + private lateinit var previousCacheData: AppCacheData + private lateinit var previousLocale: Locale + + @Before + fun setUp() { + previousLocale = Locale.getDefault() + Locale.setDefault(Locale.US) + hiltRule.inject() + viewModelStore = ViewModelStore() + + runBlocking { + previousWidgetsData = widgetsStore.data.first() + previousSettingsData = settingsStore.data.first() + previousCacheData = cacheStore.data.first() + + settingsStore.update { + it.copy( + selectedCurrency = USD, + displayUnit = BitcoinDisplayUnit.MODERN, + ) + } + cacheStore.update { it.copy(cachedRates = listOf(testUsdRate)) } + widgetsStore.restoreFromBackup( + WidgetsBackupV1( + createdAt = TEST_CREATED_AT, + widgets = WidgetsData( + widgets = listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)), + calculatorValues = emptyCalculatorValues, + ), + ) + ).getOrThrow() + + currencyRepo.currencyState.first { + it.selectedCurrency == USD && + it.displayUnit == BitcoinDisplayUnit.MODERN && + it.rates.any { rate -> rate.quote == USD && rate.lastPrice == TEST_USD_RATE } + } + widgetsRepo.widgetsDataFlow.first { + it.widgets == listOf(WidgetWithPosition(type = WidgetType.CALCULATOR, position = 0)) && + it.calculatorValues == emptyCalculatorValues + } + } + + calculatorViewModel = createCalculatorViewModel() + } + + @After + fun tearDown() { + if (::viewModelStore.isInitialized) { + viewModelStore.clear() + } + runBlocking { + widgetsStore.restoreFromBackup( + WidgetsBackupV1( + createdAt = TEST_CREATED_AT, + widgets = previousWidgetsData, + ) + ).getOrThrow() + settingsStore.update { previousSettingsData } + cacheStore.update { previousCacheData } + } + Locale.setDefault(previousLocale) + } + + @Test + fun btcInputViaNumberPadUpdatesFiatAndPersistsWidgetState() { + setCalculatorWidget() + + composeTestRule.onNodeWithTag(BTC_INPUT_TAG).performClick() + awaitNumberPad() + tapKeys("N1", "N2", "N3", "N4", "N0") + + waitForValues(btcValue = "12340", fiatValue = "12.34") + assertPersistedValues(btcValue = "12340", fiatValue = "12.34") + } + + @Test + fun fiatInputViaNumberPadUpdatesBtcAndPersistsWidgetState() { + setCalculatorWidget() + + composeTestRule.onNodeWithTag(FIAT_INPUT_TAG).performClick() + awaitNumberPad() + tapKeys("N1", "N0", "NDecimal", "N0", "N0") + + waitForValues(btcValue = "10000", fiatValue = "10.00") + assertPersistedValues(btcValue = "10000", fiatValue = "10.00") + } + + private fun createCalculatorViewModel(): CalculatorViewModel { + return ViewModelProvider( + viewModelStore, + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + return CalculatorViewModel( + widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, + ) as T + } + }, + )[CalculatorViewModel::class.java] + } + + private fun setCalculatorWidget() { + composeTestRule.setContent { + AppThemeSurface { + 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.onAllNodesWithTag(NUMBER_PAD_TAG).fetchSemanticsNodes().isNotEmpty() + } + } + + private fun tapKeys(vararg keys: String) { + keys.forEach { key -> + composeTestRule.onNodeWithTag(key).performClick() + composeTestRule.waitForIdle() + } + } + + private fun waitForValues( + btcValue: String, + fiatValue: String, + ) { + 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, + ) + composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { + widgetsRepo.widgetsDataFlow.value.calculatorValues == expectedValues + } + } + + private fun assertPersistedValues( + btcValue: String, + fiatValue: String, + ) { + assertEquals( + CalculatorValues( + btcValue = btcValue, + fiatValue = fiatValue, + satsValue = btcValue.toLong(), + displayUnit = BitcoinDisplayUnit.MODERN, + ), + widgetsRepo.widgetsDataFlow.value.calculatorValues, + ) + } + + companion object { + 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" + + private val emptyCalculatorValues = CalculatorValues( + btcValue = "", + fiatValue = "", + ) + + private val testUsdRate = FxRate( + symbol = "BTCUSD", + lastPrice = TEST_USD_RATE, + base = "BTC", + baseName = "Bitcoin", + quote = USD, + quoteName = "US Dollar", + currencySymbol = "$", + currencyFlag = "US", + lastUpdatedAt = TEST_CREATED_AT, + ) + } + + @Module + @InstallIn(SingletonComponent::class) + object TestRepoModule { + + @Provides + fun bindAmountInputHandler(currencyRepo: CurrencyRepo): AmountInputHandler = currencyRepo + + @Provides + @Named("enablePolling") + @Suppress("FunctionOnlyReturningConstant") + fun provideEnablePolling(): Boolean = false + } +} From 8b26f643d4a68a1dd7f2cdbd17005203fbf71513 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 14:39:09 -0300 Subject: [PATCH 18/33] fix: preview crash --- .../bitkit/ui/screens/wallets/HomeScreen.kt | 73 +++++++++++++++++-- 1 file changed, 66 insertions(+), 7 deletions(-) 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 a3f39298f9..f8a722efba 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 @@ -75,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 @@ -698,7 +699,6 @@ private fun BalancesSection( } } -@Suppress("CyclomaticComplexMethod", "MagicNumber", "LongMethod") @Composable private fun WidgetsPage( homeUiState: HomeUiState, @@ -711,9 +711,68 @@ private fun WidgetsPage( onClickEditWidget: (WidgetType) -> Unit, onClickDeleteWidget: (WidgetType) -> Unit, onMoveWidget: (Int, Int) -> Unit, - calculatorViewModel: CalculatorViewModel = hiltViewModel(), ) { + 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() @@ -733,14 +792,14 @@ private fun WidgetsPage( // Honor external dismiss requests (page change, profile/drawer taps, etc.). LaunchedEffect(calculatorInputDismissKey) { - if (calculatorInputDismissKey != 0) calculatorViewModel.onInputDismissed() + 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) { - calculatorViewModel.onInputDismissed() + onInputDismissed() calculatorBounds = null numberPadBounds = null } @@ -816,7 +875,7 @@ private fun WidgetsPage( widget = widget, homeUiState = homeUiState, calcState = calcState, - onSelectCalcInput = calculatorViewModel::onInputSelected, + onSelectCalcInput = onInputSelected, onCalculatorBoundsChanged = { calculatorBounds = it }, onRemoveSuggestion = onRemoveSuggestion, onClickSuggestion = onClickSuggestion, @@ -854,8 +913,8 @@ private fun WidgetsPage( btcValue = calcState.btcValue, fiatValue = calcState.fiatValue, btcPrimaryDisplayUnit = calcState.displayUnit, - onBtcChange = calculatorViewModel::onBtcInputChanged, - onFiatChange = calculatorViewModel::onFiatInputChanged, + onBtcChange = onBtcInputChanged, + onFiatChange = onFiatInputChanged, modifier = Modifier .align(Alignment.BottomCenter) .onGloballyPositioned { numberPadBounds = it.boundsInRoot() } From 0db04eaff0557e275e2272e80f3d5b44820df05b Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Wed, 3 Jun 2026 15:06:44 -0300 Subject: [PATCH 19/33] refactor: code cleanup --- app/src/test/java/to/bitkit/models/WidgetSizeTest.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt b/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt index 8ff1e261ab..de368a1940 100644 --- a/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt +++ b/app/src/test/java/to/bitkit/models/WidgetSizeTest.kt @@ -4,7 +4,6 @@ import to.bitkit.data.WidgetsData import to.bitkit.di.json import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertTrue class WidgetSizeTest { @@ -84,6 +83,5 @@ class WidgetSizeTest { val payload = WidgetsBackupV1(createdAt = 0L, widgets = data) val decoded = json.decodeFromString(json.encodeToString(payload)) assertEquals(data.widgets, decoded.widgets.widgets) - assertTrue(json.encodeToString(payload).contains("\"size\": \"small\"")) } } From 799b0786434997c464b953ad2f1a8ec48bb6a5ca Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 08:58:03 -0300 Subject: [PATCH 20/33] refactor: make isWide immutable for compose stability --- app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt | 2 +- .../bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt | 3 ++- .../java/to/bitkit/ui/screens/widgets/components/WidgetGrid.kt | 3 ++- 3 files changed, 5 insertions(+), 3 deletions(-) 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 f8a722efba..6839665b0f 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 @@ -867,7 +867,7 @@ private fun WidgetsPageContent( val visibleWidgets = homeUiState.widgetsWithPosition .filter { hasWidgetContent(it.type, homeUiState) } WidgetFlowLayout( - isWide = visibleWidgets.map { it.effectiveSize() == WidgetSize.WIDE }, + isWide = visibleWidgets.map { it.effectiveSize() == WidgetSize.WIDE }.toImmutableList(), modifier = Modifier.fillMaxWidth() ) { visibleWidgets.forEach { widget -> 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 index da6511e043..76b7a2c122 100644 --- 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 @@ -51,6 +51,7 @@ 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 @@ -94,7 +95,7 @@ fun EditableWidgetGrid( var lastTarget by remember { mutableStateOf(null) } val latestItems by rememberUpdatedState(items) - val isWide = items.map { it.effectiveSize() == WidgetSize.WIDE } + val isWide = items.map { it.effectiveSize() == WidgetSize.WIDE }.toImmutableList() Box(modifier = modifier.onGloballyPositioned { gridOrigin = it.positionInRoot() }) { LookaheadScope { 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 index 37f3579b00..029035dee1 100644 --- 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 @@ -8,6 +8,7 @@ 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( @@ -111,7 +112,7 @@ private fun axisDistance(value: Float, min: Float, max: Float): Float = when { */ @Composable fun WidgetFlowLayout( - isWide: List, + isWide: ImmutableList, modifier: Modifier = Modifier, spacing: Dp = 16.dp, smallHeight: Dp = WidgetCardDimens.COMPACT_CARD_SIZE.height, From c84244d64d741458ba1d4607bde56e857426a1e5 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 09:11:21 -0300 Subject: [PATCH 21/33] refactor: break into smaller self-explanatory private methods --- .../screens/widgets/components/WidgetGrid.kt | 58 ++++++++++++++----- 1 file changed, 42 insertions(+), 16 deletions(-) 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 index 029035dee1..c690e40b2a 100644 --- 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 @@ -44,25 +44,24 @@ fun widgetGridSlots( while (i < isWide.size) { when { isWide[i] -> { - val h = heights.getOrElse(i) { smallHeightPx } - slots.add(WidgetSlot(index = i, x = 0, y = y, width = totalWidth, height = h)) - y += h + spacingPx + val height = heightAt(heights, i, smallHeightPx) + slots.add(fullWidthSlot(index = i, y = y, totalWidth = totalWidth, height = height)) + y += height + spacingPx i += 1 } - i + 1 < isWide.size && !isWide[i + 1] -> { + canPairWithNext(isWide, i) -> { val rowHeight = maxOf( - heights.getOrElse(i) { smallHeightPx }, - heights.getOrElse(i + 1) { smallHeightPx }, + heightAt(heights, i, smallHeightPx), + heightAt(heights, i + 1, smallHeightPx), ) - slots.add(WidgetSlot(index = i, x = 0, y = y, width = columnWidth, height = rowHeight)) - slots.add( - WidgetSlot( - index = i + 1, - x = columnWidth + spacingPx, + slots.addAll( + pairedRowSlots( + index = i, y = y, - width = columnWidth, - height = rowHeight, + columnWidth = columnWidth, + spacingPx = spacingPx, + rowHeight = rowHeight, ) ) y += rowHeight + spacingPx @@ -70,9 +69,9 @@ fun widgetGridSlots( } else -> { - val h = heights.getOrElse(i) { smallHeightPx } - slots.add(WidgetSlot(index = i, x = 0, y = y, width = columnWidth, height = h)) - y += h + spacingPx + val height = heightAt(heights, i, smallHeightPx) + slots.add(leftColumnSlot(index = i, y = y, columnWidth = columnWidth, height = height)) + y += height + spacingPx i += 1 } } @@ -81,6 +80,33 @@ fun widgetGridSlots( 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 From d9c4778e1cd459aaf7c480dbb8d18458834e62dd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 09:31:24 -0300 Subject: [PATCH 22/33] fix: align name to center --- .../bitkit/ui/screens/widgets/components/EditWidgetOverlay.kt | 2 ++ .../bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) 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 index 851b1eac61..a6bdf4f185 100644 --- 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 @@ -29,6 +29,7 @@ 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 @@ -109,6 +110,7 @@ private fun EditActions( ) { BodyMSB( text = name, + textAlign = TextAlign.Center, modifier = Modifier.testTag("${name}_drag_and_drop_title") ) 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 index 76b7a2c122..9f9b44163a 100644 --- 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 @@ -47,6 +47,7 @@ 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 @@ -225,7 +226,7 @@ private fun DragPreviewCard( .padding(8.dp) ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { - BodyMSB(text = name) + BodyMSB(text = name, textAlign = TextAlign.Center) VerticalSpacer(12.dp) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf( From 125c0aa912e9fbdcd4171dd00dbceda6bc43ccb7 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 10:36:05 -0300 Subject: [PATCH 23/33] fix: adapt small card width --- .../to/bitkit/ui/screens/widgets/blocks/BlockCard.kt | 4 +++- .../ui/screens/widgets/blocks/BlocksPreviewScreen.kt | 6 +++++- .../widgets/calculator/CalculatorPreviewScreen.kt | 6 +++++- .../widgets/calculator/components/CalculatorCard.kt | 4 +++- .../to/bitkit/ui/screens/widgets/facts/FactsCard.kt | 6 +++++- .../ui/screens/widgets/facts/FactsPreviewScreen.kt | 6 +++++- .../ui/screens/widgets/headlines/HeadlineCard.kt | 11 +++++++---- .../widgets/headlines/HeadlinesPreviewScreen.kt | 6 +++++- .../bitkit/ui/screens/widgets/weather/WeatherCard.kt | 7 +++++-- .../screens/widgets/weather/WeatherPreviewScreen.kt | 6 +++++- 10 files changed, 48 insertions(+), 14 deletions(-) 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..2eeaf2d2db 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 @@ -80,7 +81,8 @@ fun BlockCardSmall( Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) .background(backgroundColor) ) { 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 59bba29111..b4c859ee16 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 @@ -26,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 @@ -128,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 = { 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 94ed44777a..76fd0cc504 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 @@ -25,6 +26,7 @@ import to.bitkit.ui.components.VerticalSpacer import to.bitkit.ui.scaffold.SheetTopBar 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 @@ -108,7 +110,9 @@ 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 = { 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 4a087fc674..14dcebd414 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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 @@ -140,7 +141,8 @@ fun CalculatorCardSmall( 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) 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..3a79c4ca7d 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 @@ -68,7 +69,8 @@ fun FactsCardSmall( ) { Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) .background(backgroundColor) ) { @@ -141,9 +143,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 019784164b..ba0e043655 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 @@ -22,6 +23,7 @@ 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 @@ -103,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 = { 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..7b4bcebae0 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,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.runtime.Composable import androidx.compose.ui.Modifier @@ -106,7 +106,8 @@ fun HeadlineCardSmall( Box( modifier = modifier - .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .fillMaxWidth() + .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) .background(backgroundColor) .clickableAlpha { @@ -202,13 +203,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 1539030fc6..6b580415c3 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 @@ -26,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 @@ -129,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 = { 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 3c1a16145f..b654144071 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 @@ -28,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 @@ -130,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 = { From 35f64d0007b5b7f3b38efc0785d2b90c39349093 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 11:00:31 -0300 Subject: [PATCH 24/33] fix: use gray6 bg for all widget cards --- .../java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt | 4 ---- .../java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt | 7 ++----- .../java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt | 6 ++---- .../to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt | 7 ++----- .../java/to/bitkit/ui/screens/widgets/price/PriceCard.kt | 7 ++----- 5 files changed, 8 insertions(+), 23 deletions(-) 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 3a92814edd..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 @@ -153,7 +153,6 @@ private fun WidgetsGalleryList( PriceCardSmall( pricePreferences = PreviewPricePreferences, priceDTO = price, - backgroundColor = Colors.Gray6, modifier = Modifier.smallPreviewCard() ) } @@ -188,7 +187,6 @@ private fun WidgetsGalleryList( source = previewArticle.publisher, link = previewArticle.link, enabled = showWidgets, - backgroundColor = Colors.Gray6, modifier = Modifier .fillMaxWidth() .testTag("headline_card_wide") @@ -205,7 +203,6 @@ private fun WidgetsGalleryList( BlockCard( preferences = PreviewBlocksPreferences, block = block ?: PreviewBlock, - backgroundColor = Colors.Gray6, ) } @@ -221,7 +218,6 @@ private fun WidgetsGalleryList( ) { FactsCardSmall( headline = fact ?: PREVIEW_FACT, - backgroundColor = Colors.Gray6, modifier = Modifier.smallPreviewCard() ) } 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 2eeaf2d2db..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 @@ -17,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 @@ -37,7 +36,6 @@ import to.bitkit.ui.theme.Colors @Composable fun BlockCard( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -48,7 +46,7 @@ fun BlockCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -71,7 +69,6 @@ fun BlockCard( @Composable fun BlockCardSmall( modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -84,7 +81,7 @@ fun BlockCardSmall( .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/facts/FactsCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt index 3a79c4ca7d..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 @@ -34,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), @@ -65,14 +64,13 @@ fun FactsCard( fun FactsCardSmall( headline: String, modifier: Modifier = Modifier, - backgroundColor: Color = Colors.White10, ) { Box( modifier = modifier .fillMaxWidth() .height(WidgetCardDimens.COMPACT_CARD_SIZE.height) .clip(shape = MaterialTheme.shapes.medium) - .background(backgroundColor) + .background(Colors.Gray6) ) { Column( modifier = Modifier 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 7b4bcebae0..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 @@ -14,7 +14,6 @@ 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, @@ -109,7 +106,7 @@ fun HeadlineCardSmall( .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) 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), From fe00c78ff32985101c0d794ed946e15da6d4cdbd Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 11:33:49 -0300 Subject: [PATCH 25/33] fix: set drag preview alpha --- .../bitkit/ui/screens/widgets/components/EditableWidgetGrid.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index 9f9b44163a..978605d210 100644 --- 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 @@ -64,6 +64,7 @@ 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) @@ -177,6 +178,7 @@ fun EditableWidgetGrid( width = with(density) { bounds.width.toDp() }, height = with(density) { bounds.height.toDp() }, ) + .alpha(DRAG_PREVIEW_ALPHA) ) { DragPreviewCard(type = type, modifier = Modifier.fillMaxSize()) } From 149f9a01dc540f716dfabc2d5360548360843413 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 11:55:19 -0300 Subject: [PATCH 26/33] fix: keep edit screen in back stack from ger --- .../bitkit/ui/screens/wallets/HomeScreen.kt | 4 +- .../java/to/bitkit/ui/sheets/WidgetsSheet.kt | 43 +++++++++++++++++-- .../to/bitkit/ui/sheets/WidgetsRouteTest.kt | 16 +++++++ 3 files changed, 57 insertions(+), 6 deletions(-) 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 6839665b0f..d3ff1f7456 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 @@ -174,7 +174,7 @@ 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.toWidgetsPreviewRoute +import to.bitkit.ui.sheets.toWidgetsEditRoute import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets @@ -345,7 +345,7 @@ fun HomeScreen( onClickEditWidgetList = homeViewModel::onClickEditWidgetList, onClickEditWidget = { widgetType -> homeViewModel.disableEditMode() - appViewModel.showSheet(Sheet.Widgets(widgetType.toWidgetsPreviewRoute())) + widgetType.toWidgetsEditRoute()?.let { appViewModel.showSheet(Sheet.Widgets(it)) } }, onClickDeleteWidget = { widgetType -> homeViewModel.displayAlertDeleteWidget(widgetType) 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 c1ee9b82be..7b8fd47cdf 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt @@ -171,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() ) } @@ -192,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() ) } @@ -213,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() ) } @@ -238,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() ) } @@ -330,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/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()) + } } From 6822332053117056f3c96bf834358b6495ea2f3f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 12:02:52 -0300 Subject: [PATCH 27/33] fix: always enable widget settings button --- .../to/bitkit/ui/screens/wallets/HomeScreen.kt | 4 +++- .../widgets/components/EditWidgetOverlay.kt | 16 ++-------------- 2 files changed, 5 insertions(+), 15 deletions(-) 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 d3ff1f7456..59a1930023 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 @@ -175,6 +175,7 @@ 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 import to.bitkit.ui.theme.Insets @@ -345,7 +346,8 @@ fun HomeScreen( onClickEditWidgetList = homeViewModel::onClickEditWidgetList, onClickEditWidget = { widgetType -> homeViewModel.disableEditMode() - widgetType.toWidgetsEditRoute()?.let { appViewModel.showSheet(Sheet.Widgets(it)) } + val route = widgetType.toWidgetsEditRoute() ?: widgetType.toWidgetsPreviewRoute() + appViewModel.showSheet(Sheet.Widgets(route)) }, onClickDeleteWidget = { widgetType -> homeViewModel.displayAlertDeleteWidget(widgetType) 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 index a6bdf4f185..547e9b56f2 100644 --- 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 @@ -16,7 +16,6 @@ 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 @@ -37,8 +36,6 @@ 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, WidgetType.FACTS, WidgetType.CALCULATOR) - /** * 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. @@ -55,7 +52,6 @@ fun EditWidgetOverlay( content: @Composable () -> Unit, ) { val name = stringResource(type.title) - val settingsEnabled = type !in SETTINGS_DISABLED_TYPES Box( modifier = modifier @@ -81,7 +77,6 @@ fun EditWidgetOverlay( EditActions( name = name, - settingsEnabled = settingsEnabled, onDelete = onDelete, onSettings = onSettings, dragHandleModifier = dragHandleModifier, @@ -95,7 +90,6 @@ private val CARD_CORNER_RADIUS = 16.dp @Composable private fun EditActions( name: String, - settingsEnabled: Boolean, onDelete: () -> Unit, onSettings: () -> Unit, dragHandleModifier: Modifier, @@ -128,7 +122,6 @@ private fun EditActions( iconRes = R.drawable.ic_settings, contentDescription = stringResource(R.string.common__edit), onClick = onSettings, - enabled = settingsEnabled, modifier = Modifier.testTag("${name}_WidgetActionEdit") ) @@ -149,26 +142,21 @@ private fun EditActionIcon( 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) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) ) { Icon( painter = painterResource(iconRes), contentDescription = contentDescription, - modifier = Modifier - .size(24.dp) - .alpha(if (enabled) 1f else DISABLED_ALPHA) + modifier = Modifier.size(24.dp) ) } } -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))) From 4b6a497916b8218b042edc947b279f0aedbfc6a0 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 12:10:39 -0300 Subject: [PATCH 28/33] feat: edge-to-edge scroll for widget size pager --- .../widgets/components/WidgetSizeCarousel.kt | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 11c2095094..67f74b6504 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 @@ -4,6 +4,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -18,9 +19,11 @@ 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 @@ -71,9 +74,11 @@ fun WidgetSizeCarousel( ) { HorizontalPager( state = pagerState, + contentPadding = PaddingValues(horizontal = PAGER_HORIZONTAL_PADDING), modifier = Modifier - .fillMaxWidth() .weight(1f) + .bleedHorizontal(PAGER_HORIZONTAL_PADDING) + .fillMaxWidth() .testTag("widget_size_pager") ) { page -> Box( @@ -128,6 +133,14 @@ 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 @@ -140,3 +153,5 @@ private fun initialPageFor(size: WidgetSize, supportsSmall: Boolean): Int { private const val PAGE_SMALL = 0 private const val PAGE_WIDE = 1 + +private val PAGER_HORIZONTAL_PADDING = 16.dp From a2d0009760cacebaa1e2135e249f8a9e0ddc0337 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 13:09:02 -0300 Subject: [PATCH 29/33] feat: edge-to-edge scroll for widget size pager --- .../to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt | 2 +- .../ui/screens/widgets/calculator/CalculatorPreviewScreen.kt | 2 +- .../bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt | 1 + .../to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt | 2 +- .../ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt | 2 +- .../to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt | 2 +- .../bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt | 2 +- 7 files changed, 7 insertions(+), 6 deletions(-) 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 b4c859ee16..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 @@ -160,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/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 76fd0cc504..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 @@ -142,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/components/WidgetSizeCarousel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/components/WidgetSizeCarousel.kt index 67f74b6504..dc7ee5d90f 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 @@ -115,6 +115,7 @@ fun WidgetSizeCarousel( horizontalArrangement = Arrangement.Center, modifier = Modifier .fillMaxWidth() + .padding(vertical = 6.dp) .testTag("page_indicator") ) { repeat(pageCount) { index -> 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 ba0e043655..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 @@ -133,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/headlines/HeadlinesPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt index 6b580415c3..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 @@ -164,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/price/PricePreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt index f696910a4b..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 @@ -180,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/weather/WeatherPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt index b654144071..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 @@ -162,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") From c953b96411c439456ee0eb9239432d8959d47a93 Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 13:14:50 -0300 Subject: [PATCH 30/33] fix: remove adjacent page peek in widget carousel --- .../ui/screens/widgets/components/WidgetSizeCarousel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 dc7ee5d90f..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 @@ -4,7 +4,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -74,7 +73,6 @@ fun WidgetSizeCarousel( ) { HorizontalPager( state = pagerState, - contentPadding = PaddingValues(horizontal = PAGER_HORIZONTAL_PADDING), modifier = Modifier .weight(1f) .bleedHorizontal(PAGER_HORIZONTAL_PADDING) @@ -83,7 +81,9 @@ fun WidgetSizeCarousel( ) { page -> Box( contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = PAGER_HORIZONTAL_PADDING) ) { when (pageToSize(page, supportsSmall)) { WidgetSize.SMALL -> smallContent() From f23f0cbd4ba58fa0d3be50527fb5bc9193c69a9f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 13:37:33 -0300 Subject: [PATCH 31/33] fix: show decimal placeholder in small calculator --- .../screens/widgets/calculator/components/CalculatorCard.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 14dcebd414..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 @@ -151,6 +151,7 @@ fun CalculatorCardSmall( ReadOnlyRow( currencySymbol = BITCOIN_SYMBOL, value = formatBitcoinValue(btcValue, btcPrimaryDisplayUnit), + placeholder = formatBitcoinPlaceholder(btcValue, btcPrimaryDisplayUnit), iconSize = 24.dp, rowPadding = 12.dp, isActive = activeInput == MoneyType.BITCOIN, @@ -162,6 +163,7 @@ fun CalculatorCardSmall( ReadOnlyRow( currencySymbol = fiatSymbol, value = formatFiatValue(fiatValue), + placeholder = formatFiatPlaceholder(fiatValue), iconSize = 24.dp, rowPadding = 12.dp, isActive = activeInput == MoneyType.FIAT, @@ -180,6 +182,7 @@ private fun ReadOnlyRow( iconSize: Dp, rowPadding: Dp, modifier: Modifier = Modifier, + placeholder: String = "", isActive: Boolean = false, onClick: (() -> Unit)? = null, ) { @@ -209,7 +212,7 @@ private fun ReadOnlyRow( } InputValue( value = value, - placeholder = "", + placeholder = placeholder, isActive = isActive, modifier = Modifier.weight(1f) ) From b2c2cbab6f3a2e5a64c33c33e50ee786350eb77f Mon Sep 17 00:00:00 2001 From: jvsena42 Date: Thu, 4 Jun 2026 14:58:04 -0300 Subject: [PATCH 32/33] fix: disable widget settings only for suggestions --- .../widgets/components/EditWidgetOverlay.kt | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) 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 index 547e9b56f2..01edc59e0d 100644 --- 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 @@ -16,6 +16,7 @@ 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 @@ -36,6 +37,8 @@ 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. @@ -52,6 +55,7 @@ fun EditWidgetOverlay( content: @Composable () -> Unit, ) { val name = stringResource(type.title) + val settingsEnabled = type !in SETTINGS_DISABLED_TYPES Box( modifier = modifier @@ -77,6 +81,7 @@ fun EditWidgetOverlay( EditActions( name = name, + settingsEnabled = settingsEnabled, onDelete = onDelete, onSettings = onSettings, dragHandleModifier = dragHandleModifier, @@ -90,6 +95,7 @@ private val CARD_CORNER_RADIUS = 16.dp @Composable private fun EditActions( name: String, + settingsEnabled: Boolean, onDelete: () -> Unit, onSettings: () -> Unit, dragHandleModifier: Modifier, @@ -122,6 +128,7 @@ private fun EditActions( iconRes = R.drawable.ic_settings, contentDescription = stringResource(R.string.common__edit), onClick = onSettings, + enabled = settingsEnabled, modifier = Modifier.testTag("${name}_WidgetActionEdit") ) @@ -142,21 +149,26 @@ private fun EditActionIcon( 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(onClick = onClick) else Modifier) + .then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier) ) { Icon( painter = painterResource(iconRes), contentDescription = contentDescription, - modifier = Modifier.size(24.dp) + 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))) From 3d224825dd83e5d8509f19ad2b1adcec9956a23d Mon Sep 17 00:00:00 2001 From: Piotr Stachyra Date: Thu, 4 Jun 2026 20:37:59 +0200 Subject: [PATCH 33/33] test: CalculatorWidget --- app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt | 1 + docs/e2e-test-ids.md | 1 + 2 files changed, 2 insertions(+) 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 59a1930023..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 @@ -1148,6 +1148,7 @@ private fun WidgetCardContent( val calcModifier = Modifier .fillMaxWidth() .onGloballyPositioned { onCalculatorBoundsChanged(it.boundsInRoot()) } + .testTag("CalculatorWidget") if (small) { CalculatorCardSmall( btcPrimaryDisplayUnit = calcState.displayUnit, 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 | ✅ |