diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt deleted file mode 100644 index 7f82d68f3f..0000000000 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreenTest.kt +++ /dev/null @@ -1,373 +0,0 @@ -package to.bitkit.ui.screens.widgets.blocks - -import androidx.compose.ui.test.assertIsEnabled -import androidx.compose.ui.test.assertIsNotEnabled -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test -import to.bitkit.models.widget.BlockModel -import to.bitkit.models.widget.BlocksPreferences -import to.bitkit.ui.theme.AppThemeSurface - -class BlocksEditScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private val testBlock = BlockModel( - height = "761,405", - time = "01:31:42 UTC", - date = "11/2/2022", - transactionCount = "2,175", - size = "1,606Kb", - source = "mempool.io", - fees = "25 059 357", - ) - - private val defaultPreferences = BlocksPreferences() - - @Test - fun testBlocksEditScreenWithDefaultPreferences() { - // Arrange - var backClicked = false - var blockClicked = false - var timeClicked = false - var dateClicked = false - var transactionsClicked = false - var sizeClicked = false - var feesClicked = false - var sourceClicked = false - var resetClicked = false - var previewClicked = false - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = { backClicked = true }, - onClickShowBlock = { blockClicked = true }, - onClickShowTime = { timeClicked = true }, - onClickShowDate = { dateClicked = true }, - onClickShowTransactions = { transactionsClicked = true }, - onClickShowSize = { sizeClicked = true }, - onClickShowFees = { feesClicked = true }, - onClickShowSource = { sourceClicked = true }, - onClickReset = { resetClicked = true }, - onClickPreview = { previewClicked = true }, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Assert main elements exist - composeTestRule.onNodeWithTag("blocks_edit_screen").assertExists() - composeTestRule.onNodeWithTag("WidgetEditScrollView").assertExists() - - // Verify section header - composeTestRule.onNodeWithTag("data_section_header").assertExists() - - // Verify all setting rows exist - listOf("block", "time", "date", "transactions", "size", "fees", "source").forEach { prefix -> - composeTestRule.onNodeWithTag("${prefix}_setting_row").assertExists() - composeTestRule.onNodeWithTag("${prefix}_label").assertExists() - composeTestRule.onNodeWithTag("${prefix}_leading_icon", useUnmergedTree = true).assertExists() - if (testBlock.getFieldValue(prefix).isNotEmpty()) { - composeTestRule.onNodeWithTag("${prefix}_text").assertExists() - } - composeTestRule.onNodeWithTag("${prefix}_toggle_button").assertExists() - composeTestRule.onNodeWithTag("${prefix}_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("${prefix}_divider").assertExists() - } - - // Verify buttons - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetEditReset").assertExists() - composeTestRule.onNodeWithTag("WidgetEditPreview").assertExists() - - // Test button clicks - composeTestRule.onNodeWithTag("block_toggle_button").performClick() - assert(blockClicked) - - composeTestRule.onNodeWithTag("WidgetEditPreview").performClick() - assert(previewClicked) - - // Reset button should be disabled with default preferences - composeTestRule.onNodeWithTag("WidgetEditReset").assertIsNotEnabled() - } - - @Test - fun testBlocksEditScreenWithCustomPreferences() { - // Arrange - Some options enabled - val customPreferences = BlocksPreferences( - showBlock = true, - showTime = true, - showSource = true - ) - - var resetClicked = false - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = {}, - onClickShowTime = {}, - onClickShowDate = {}, - onClickShowTransactions = {}, - onClickShowSize = {}, - onClickShowFees = {}, - onClickShowSource = {}, - onClickReset = { resetClicked = true }, - onClickPreview = {}, - blocksPreferences = customPreferences, - block = testBlock - ) - } - } - - // Assert reset button should be enabled when preferences are customized - composeTestRule.onNodeWithTag("WidgetEditReset").assertIsEnabled() - - // Test reset button click - composeTestRule.onNodeWithTag("WidgetEditReset").performClick() - assert(resetClicked) - } - - @Test - fun testPreviewButtonEnabledState() { - // Test when preview should be enabled (at least one option enabled) - val preferencesSomeEnabled = BlocksPreferences(showBlock = true) - - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = {}, - onClickShowTime = {}, - onClickShowDate = {}, - onClickShowTransactions = {}, - onClickShowSize = {}, - onClickShowFees = {}, - onClickShowSource = {}, - onClickReset = {}, - onClickPreview = {}, - blocksPreferences = preferencesSomeEnabled, - block = testBlock - ) - } - } - - composeTestRule.onNodeWithTag("WidgetEditPreview").assertIsEnabled() - } - - @Test - fun testPreviewButtonDisabledState() { - // Test when preview should be disabled (all options disabled) - val preferencesAllDisabled = BlocksPreferences( - showBlock = false, - showTime = false, - showDate = false, - showTransactions = false, - showSize = false, - showFees = false, - showSource = false - ) - - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = {}, - onClickShowTime = {}, - onClickShowDate = {}, - onClickShowTransactions = {}, - onClickShowSize = {}, - onClickShowFees = {}, - onClickShowSource = {}, - onClickReset = {}, - onClickPreview = {}, - blocksPreferences = preferencesAllDisabled, - block = testBlock - ) - } - } - - composeTestRule.onNodeWithTag("WidgetEditPreview").assertIsNotEnabled() - } - - @Test - fun testAllCallbacksTriggered() { - // Arrange - var blockClicked = false - var timeClicked = false - var dateClicked = false - var transactionsClicked = false - var sizeClicked = false - var feesClicked = false - var sourceClicked = false - var resetClicked = false - var previewClicked = false - - val customPreferences = BlocksPreferences( - showBlock = false, - showTime = false, - showDate = false, - showTransactions = false, - showSize = true, - showFees = false, - showSource = true - ) - - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = { blockClicked = true }, - onClickShowTime = { timeClicked = true }, - onClickShowDate = { dateClicked = true }, - onClickShowTransactions = { transactionsClicked = true }, - onClickShowSize = { sizeClicked = true }, - onClickShowFees = { feesClicked = true }, - onClickShowSource = { sourceClicked = true }, - onClickReset = { resetClicked = true }, - onClickPreview = { previewClicked = true }, - blocksPreferences = customPreferences, - block = testBlock - ) - } - } - - // Test all clickable elements - composeTestRule.onNodeWithTag("block_toggle_button").performClick() - assert(blockClicked) - - composeTestRule.onNodeWithTag("time_toggle_button").performClick() - assert(timeClicked) - - composeTestRule.onNodeWithTag("date_toggle_button").performClick() - assert(dateClicked) - - composeTestRule.onNodeWithTag("transactions_toggle_button").performClick() - assert(transactionsClicked) - - composeTestRule.onNodeWithTag("size_toggle_button").performClick() - assert(sizeClicked) - - composeTestRule.onNodeWithTag("fees_toggle_button").performClick() - assert(feesClicked) - - composeTestRule.onNodeWithTag("source_toggle_button").performClick() - assert(sourceClicked) - - composeTestRule.onNodeWithTag("WidgetEditPreview").performClick() - assert(previewClicked) - - composeTestRule.onNodeWithTag("WidgetEditReset").performClick() - assert(resetClicked) - } - - @Test - fun testEmptyValuesDisplay() { - // Arrange - Block with empty values - val emptyBlock = BlockModel( - height = "", - time = "", - date = "", - transactionCount = "", - size = "", - source = "", - fees = "", - ) - - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = {}, - onClickShowTime = {}, - onClickShowDate = {}, - onClickShowTransactions = {}, - onClickShowSize = {}, - onClickShowFees = {}, - onClickShowSource = {}, - onClickReset = {}, - onClickPreview = {}, - blocksPreferences = defaultPreferences, - block = emptyBlock - ) - } - } - - // Assert that text elements don't exist when values are empty - listOf("block", "time", "date", "transactions", "size", "fees", "source").forEach { prefix -> - composeTestRule.onNodeWithTag("${prefix}_text").assertDoesNotExist() - } - } - - @Test - fun testAllElementsExist() { - // Arrange - composeTestRule.setContent { - AppThemeSurface { - BlocksEditContent( - onBack = {}, - onClickShowBlock = {}, - onClickShowTime = {}, - onClickShowDate = {}, - onClickShowTransactions = {}, - onClickShowSize = {}, - onClickShowFees = {}, - onClickShowSource = {}, - onClickReset = {}, - onClickPreview = {}, - blocksPreferences = BlocksPreferences( - showBlock = false, - showTime = false, - showDate = false, - showTransactions = false, - showSize = true, - showFees = false, - showSource = true - ), - block = testBlock - ) - } - } - - // Assert all tagged elements exist - composeTestRule.onNodeWithTag("blocks_edit_screen").assertExists() - composeTestRule.onNodeWithTag("WidgetEditScrollView").assertExists() - composeTestRule.onNodeWithTag("data_section_header").assertExists() - - listOf("block", "time", "date", "transactions", "size", "fees", "source").forEach { prefix -> - composeTestRule.onNodeWithTag("${prefix}_setting_row").assertExists() - composeTestRule.onNodeWithTag("${prefix}_label").assertExists() - composeTestRule.onNodeWithTag("${prefix}_leading_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("${prefix}_toggle_button").assertExists() - composeTestRule.onNodeWithTag("${prefix}_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("${prefix}_divider").assertExists() - } - - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetEditReset").assertExists() - composeTestRule.onNodeWithTag("WidgetEditPreview").assertExists() - } -} - -// Helper extension function to get field values from BlockModel -private fun BlockModel.getFieldValue(prefix: String): String { - return when (prefix) { - "block" -> height - "time" -> time - "date" -> date - "transactions" -> transactionCount - "size" -> size - "fees" -> fees - "source" -> source - else -> "" - } -} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt deleted file mode 100644 index e85b589ab5..0000000000 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreenTest.kt +++ /dev/null @@ -1,313 +0,0 @@ -package to.bitkit.ui.screens.widgets.blocks - -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.junit4.createComposeRule -import androidx.compose.ui.test.onNodeWithTag -import androidx.compose.ui.test.performClick -import org.junit.Rule -import org.junit.Test -import to.bitkit.models.widget.BlockModel -import to.bitkit.models.widget.BlocksPreferences -import to.bitkit.ui.theme.AppThemeSurface - -class BlocksPreviewContentTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private val testBlock = BlockModel( - height = "123456", - time = "01:31:42 UTC", - date = "2023-01-01", - transactionCount = "2,175", - size = "1,606kB", - source = "mempool.space", - fees = "25 059 357", - ) - private val defaultPreferences = BlocksPreferences() - - @Test - fun testBlocksPreviewWithEnabledWidget() { - // Arrange - var backClicked = false - var editClicked = false - var deleteClicked = false - var saveClicked = false - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = { backClicked = true }, - onClickEdit = { editClicked = true }, - onClickDelete = { deleteClicked = true }, - onClickSave = { saveClicked = true }, - isBlocksWidgetEnabled = true, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Assert main elements exist - composeTestRule.onNodeWithTag("blocks_preview_screen").assertExists() - composeTestRule.onNodeWithTag("main_content").assertExists() - - // Verify header elements - composeTestRule.onNodeWithTag("header_row").assertExists() - composeTestRule.onNodeWithTag("widget_title").assertExists() - composeTestRule.onNodeWithTag("widget_icon").assertExists() - composeTestRule.onNodeWithTag("widget_description").assertExists() - - // Verify settings and preview section - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("preview_label").assertExists() - composeTestRule.onNodeWithTag("block_card").assertExists() - - // Verify buttons - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetDelete").assertExists() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - - // Test button clicks - composeTestRule.onNodeWithTag("WidgetEdit").performClick() - assert(editClicked) - - composeTestRule.onNodeWithTag("WidgetDelete").performClick() - assert(deleteClicked) - - composeTestRule.onNodeWithTag("WidgetSave").performClick() - assert(saveClicked) - } - - @Test - fun testBlocksPreviewWithDisabledWidget() { - // Arrange - var backClicked = false - var editClicked = false - var deleteClicked = false - var saveClicked = false - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = { backClicked = true }, - onClickEdit = { editClicked = true }, - onClickDelete = { deleteClicked = true }, - onClickSave = { saveClicked = true }, - isBlocksWidgetEnabled = false, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Assert main elements exist - composeTestRule.onNodeWithTag("blocks_preview_screen").assertExists() - composeTestRule.onNodeWithTag("buttons_row").assertExists() - - // Delete button should not exist when widget is disabled - composeTestRule.onNodeWithTag("WidgetDelete").assertDoesNotExist() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - - // Test save button click - composeTestRule.onNodeWithTag("WidgetSave").performClick() - assert(saveClicked) - } - - @Test - fun testCustomBlocksPreferences() { - // Arrange - val customPreferences = BlocksPreferences( - showBlock = true, - showTime = true, - showDate = false, - showTransactions = true, - showSize = false, - showSource = true - ) - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = customPreferences, - block = testBlock - ) - } - } - - // Assert that all elements still exist with custom preferences - composeTestRule.onNodeWithTag("blocks_preview_screen").assertExists() - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("block_card").assertExists() - } - - @Test - fun testAllElementsExist() { - // Arrange - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Assert all tagged elements exist - composeTestRule.onNodeWithTag("blocks_preview_screen").assertExists() - composeTestRule.onNodeWithTag("main_content").assertExists() - composeTestRule.onNodeWithTag("header_row").assertExists() - composeTestRule.onNodeWithTag("widget_title").assertExists() - composeTestRule.onNodeWithTag("widget_icon").assertExists() - composeTestRule.onNodeWithTag("widget_description").assertExists() - composeTestRule.onNodeWithTag("divider").assertExists() - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("preview_label").assertExists() - composeTestRule.onNodeWithTag("block_card").assertExists() - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetDelete").assertExists() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - } - - @Test - fun testNavigationCallbacks() { - // Arrange - var backClicked = false - - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = { backClicked = true }, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Note: Navigation callbacks are tested through the actual navigation components - } - - @Test - fun testWithMinimalBlocksPreferences() { - // Arrange - val minimalPreferences = BlocksPreferences( - showBlock = true, - showTime = false, - showDate = false, - showTransactions = false, - showSize = false, - showSource = false - ) - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = false, - blocksPreferences = minimalPreferences, - block = testBlock - ) - } - } - - // Assert core elements still exist - composeTestRule.onNodeWithTag("blocks_preview_screen").assertExists() - composeTestRule.onNodeWithTag("block_card").assertExists() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - composeTestRule.onNodeWithTag("WidgetDelete").assertDoesNotExist() - } - - @Test - fun testBlockCardVisibility() { - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = defaultPreferences, - block = testBlock - ) - } - } - - // Assert block card is displayed with correct content - composeTestRule.onNodeWithTag("block_card").assertIsDisplayed() - } - - @Test - fun testEditButtonShowsCustomState() { - // Arrange with custom preferences - val customPreferences = BlocksPreferences( - showBlock = true, - showTime = true, - showDate = false, - showTransactions = true, - showSize = false, - showSource = true - ) - - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = customPreferences, - block = testBlock - ) - } - } - - // Assert edit button shows custom state - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - } - - @Test - fun testNullBlockCase() { - // Act - composeTestRule.setContent { - AppThemeSurface { - BlocksPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - isBlocksWidgetEnabled = true, - blocksPreferences = defaultPreferences, - block = null - ) - } - } - - // Assert block card doesn't exist when block is null - composeTestRule.onNodeWithTag("block_card").assertDoesNotExist() - } -} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt index f7e8eb998a..afdc713360 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherCardTest.kt @@ -7,6 +7,7 @@ import org.junit.Rule import org.junit.Test import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface @@ -17,204 +18,128 @@ class WeatherCardTest { val composeTestRule = createComposeRule() private val testGoodWeatherModel = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = FeeCondition.GOOD.icon + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, ) private val testAverageWeatherModel = WeatherModel( + condition = FeeCondition.AVERAGE, title = R.string.widgets__weather__condition__average__title, + shortTitle = R.string.widgets__weather__condition__average__short_title, description = R.string.widgets__weather__condition__average__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = FeeCondition.AVERAGE.icon - ) - - private val testAllEnabledPreferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = true + currentFee = "$ 1.27", + currentFeeSats = 1270L, + currentFeeSatsFormatted = "1,270 ₿", + nextBlockFee = "12 ₿/vByte", + icon = FeeCondition.AVERAGE.icon, ) @Test - fun testWeatherCardWithAllElementsAndWidgetTitle() { - // Arrange & Act + fun testWeatherCardShowsTitleDescriptionAndCurrentFeeFiat() { composeTestRule.setContent { AppThemeSurface { WeatherCard( - showWidgetTitle = true, weatherModel = testGoodWeatherModel, - preferences = testAllEnabledPreferences + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), ) } } - // Assert all elements exist - composeTestRule.onNodeWithTag("weather_card_widget_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_condition_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_widget_title_text", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_description_text", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_next_block_row", useUnmergedTree = true).assertExists() - - // Verify text content - composeTestRule.onNodeWithTag("weather_card_current_fee_value", useUnmergedTree = true) - .assertTextEquals(testGoodWeatherModel.currentFee) - composeTestRule.onNodeWithTag("weather_card_next_block_fee_value", useUnmergedTree = true) - .assertTextEquals(testGoodWeatherModel.nextBlockFee) - } + composeTestRule.onNodeWithTag("weather_card").assertExists() + composeTestRule.onNodeWithTag("weather_card_title", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_description", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_icon", useUnmergedTree = true).assertExists() - @Test - fun testWeatherCardWithoutWidgetTitle() { - // Arrange & Act - composeTestRule.setContent { - AppThemeSurface { - WeatherCard( - showWidgetTitle = false, - weatherModel = testGoodWeatherModel, - preferences = testAllEnabledPreferences - ) - } - } - - // Assert main elements exist - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_description_text", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_block", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_value", useUnmergedTree = true) + .assertTextEquals(testGoodWeatherModel.currentFee) - // Assert widget title elements do not exist - composeTestRule.onNodeWithTag("weather_card_widget_title_row", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_condition_icon", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_widget_title_text", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_current_fee_sats_block", useUnmergedTree = true) + .assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_next_block_block", useUnmergedTree = true) + .assertDoesNotExist() } @Test - fun testWeatherCardWithoutNextBlockFee() { - // Arrange & Act + fun testWeatherCardShowsNextBlockOnly() { composeTestRule.setContent { AppThemeSurface { WeatherCard( - showWidgetTitle = true, weatherModel = testGoodWeatherModel, - preferences = testAllEnabledPreferences.copy(showNextBlockFee = false) + preferences = WeatherPreferences(selectedOption = WeatherDataOption.NEXT_BLOCK_INCLUSION), ) } } - // Assert main elements exist - composeTestRule.onNodeWithTag("weather_card_widget_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_row", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_next_block_block", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_next_block_value", useUnmergedTree = true) + .assertTextEquals(testGoodWeatherModel.nextBlockFee) - // Assert next block fee elements do not exist - composeTestRule.onNodeWithTag("weather_card_next_block_row", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_next_block_fee_label", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_next_block_fee_value", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_block", useUnmergedTree = true) + .assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_current_fee_sats_block", useUnmergedTree = true) + .assertDoesNotExist() } @Test - fun testWeatherCardMinimalConfiguration() { - // Arrange & Act - Only title shown + fun testWeatherCardWithNothingSelectedHidesFeeBlock() { composeTestRule.setContent { AppThemeSurface { WeatherCard( - showWidgetTitle = false, weatherModel = testGoodWeatherModel, - preferences = WeatherPreferences( - showTitle = true, - showDescription = false, - showCurrentFee = false, - showNextBlockFee = false - ) + preferences = WeatherPreferences(selectedOption = null), ) } } - // Assert only title row exists - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_title", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_description", useUnmergedTree = true).assertExists() - // Assert other elements do not exist - composeTestRule.onNodeWithTag("weather_card_widget_title_row", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_description_text", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_current_fee_row", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_next_block_row", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_block", useUnmergedTree = true) + .assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_current_fee_sats_block", useUnmergedTree = true) + .assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_next_block_block", useUnmergedTree = true) + .assertDoesNotExist() } @Test - fun testWeatherCardWithDifferentConditions() { - // Arrange & Act - Test with average condition + fun testWeatherCardSwitchesContentBasedOnConditionAndSelection() { composeTestRule.setContent { AppThemeSurface { WeatherCard( - showWidgetTitle = true, weatherModel = testAverageWeatherModel, - preferences = testAllEnabledPreferences + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), ) } } - // Assert elements exist with average condition data - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_value", useUnmergedTree = true) + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_value", useUnmergedTree = true) .assertTextEquals(testAverageWeatherModel.currentFee) - composeTestRule.onNodeWithTag("weather_card_next_block_fee_value", useUnmergedTree = true) - .assertTextEquals(testAverageWeatherModel.nextBlockFee) } @Test - fun testAllElementsExistInFullConfiguration() { - // Arrange & Act + fun testWeatherCardSmallShowsTitleAndFee() { composeTestRule.setContent { AppThemeSurface { - WeatherCard( - showWidgetTitle = true, + WeatherCardSmall( weatherModel = testGoodWeatherModel, - preferences = testAllEnabledPreferences + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), ) } } - // Assert all tagged elements exist - composeTestRule.onNodeWithTag("weather_card_widget_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_condition_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_widget_title_text", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_description_text", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_label", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_current_fee_value", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_next_block_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_next_block_fee_label", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_next_block_fee_value", useUnmergedTree = true).assertExists() - } - - @Test - fun testWeatherCardWithTitleAndDescriptionOnly() { - // Arrange & Act - composeTestRule.setContent { - AppThemeSurface { - WeatherCard( - showWidgetTitle = false, - weatherModel = testGoodWeatherModel, - preferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = false, - showNextBlockFee = false - ) - ) - } - } - - // Assert title and description exist - composeTestRule.onNodeWithTag("weather_card_title_row", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("weather_card_description_text", useUnmergedTree = true).assertExists() - - // Assert fee elements do not exist - composeTestRule.onNodeWithTag("weather_card_current_fee_row", useUnmergedTree = true).assertDoesNotExist() - composeTestRule.onNodeWithTag("weather_card_next_block_row", useUnmergedTree = true).assertDoesNotExist() + composeTestRule.onNodeWithTag("weather_card_small").assertExists() + composeTestRule.onNodeWithTag("weather_card_small_title", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_icon", useUnmergedTree = true).assertExists() + composeTestRule.onNodeWithTag("weather_card_current_fee_fiat_value", useUnmergedTree = true) + .assertTextEquals(testGoodWeatherModel.currentFee) } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt index b58f05596d..2854d79746 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreenTest.kt @@ -9,6 +9,7 @@ import org.junit.Rule import org.junit.Test import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface @@ -19,129 +20,85 @@ class WeatherEditScreenTest { val composeTestRule = createComposeRule() private val testWeatherModel = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = FeeCondition.GOOD.icon + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, ) - private val defaultPreferences = WeatherPreferences() + private val rowPrefixes = listOf("current_fee_fiat", "current_fee_sats", "next_block") @Test fun testWeatherEditScreenWithDefaultPreferences() { - // Arrange - var backClicked = false - var titleClicked = false - var descriptionClicked = false - var currentFeeClicked = false - var nextBlockFeeClicked = false - var resetClicked = false - var previewClicked = false - - // Act composeTestRule.setContent { AppThemeSurface { WeatherEditContent( - onBack = { backClicked = true }, - onClickShowTitle = { titleClicked = true }, - onClickShowDescription = { descriptionClicked = true }, - onClickShowCurrentFee = { currentFeeClicked = true }, - onClickShowNextBlockFee = { nextBlockFeeClicked = true }, - onClickReset = { resetClicked = true }, - onClickPreview = { previewClicked = true }, - weatherPreferences = defaultPreferences, - weather = testWeatherModel + onBack = {}, + onSelectOption = {}, + onClickReset = {}, + onClickPreview = {}, + weatherPreferences = WeatherPreferences(), + weather = testWeatherModel, ) } } - // Assert main elements exist composeTestRule.onNodeWithTag("weather_edit_screen").assertExists() composeTestRule.onNodeWithTag("WidgetEditScrollView").assertExists() + composeTestRule.onNodeWithTag("display_section_header").assertExists() - // Verify description - composeTestRule.onNodeWithTag("edit_description").assertExists() - - // Verify all setting rows exist - listOf("title", "description", "current_fee", "next_block_fee").forEach { prefix -> + rowPrefixes.forEach { prefix -> composeTestRule.onNodeWithTag("${prefix}_setting_row").assertExists() - composeTestRule.onNodeWithTag("${prefix}_text").assertExists() + composeTestRule.onNodeWithTag("${prefix}_label").assertExists() + composeTestRule.onNodeWithTag("${prefix}_value").assertExists() composeTestRule.onNodeWithTag("${prefix}_toggle_button").assertExists() composeTestRule.onNodeWithTag("${prefix}_toggle_icon", useUnmergedTree = true).assertExists() composeTestRule.onNodeWithTag("${prefix}_divider").assertExists() } - // Verify buttons composeTestRule.onNodeWithTag("buttons_row").assertExists() composeTestRule.onNodeWithTag("WidgetEditReset").assertExists() composeTestRule.onNodeWithTag("WidgetEditPreview").assertExists() - - // Test button clicks - composeTestRule.onNodeWithTag("title_toggle_button").performClick() - assert(titleClicked) - - composeTestRule.onNodeWithTag("WidgetEditPreview").performClick() - assert(previewClicked) - - // Reset button should be disabled with default preferences - composeTestRule.onNodeWithTag("WidgetEditReset").assertIsNotEnabled() } @Test - fun testWeatherEditScreenWithCustomPreferences() { - // Arrange - Some options enabled - val customPreferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = false, - showNextBlockFee = true - ) - + fun testResetButtonEnabledWhenPreferencesDifferFromDefault() { var resetClicked = false - // Act composeTestRule.setContent { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = { resetClicked = true }, onClickPreview = {}, - weatherPreferences = customPreferences, - weather = testWeatherModel + weatherPreferences = WeatherPreferences(selectedOption = WeatherDataOption.NEXT_BLOCK_INCLUSION), + weather = testWeatherModel, ) } } - // Assert reset button should be enabled when preferences are customized composeTestRule.onNodeWithTag("WidgetEditReset").assertIsEnabled() - - // Test reset button click composeTestRule.onNodeWithTag("WidgetEditReset").performClick() assert(resetClicked) } @Test - fun testPreviewButtonEnabledState() { - // Test when preview should be enabled (at least one option enabled) - val preferencesSomeEnabled = WeatherPreferences(showTitle = true) - + fun testPreviewButtonEnabledWhenAnyOptionSelected() { composeTestRule.setContent { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = {}, onClickPreview = {}, - weatherPreferences = preferencesSomeEnabled, - weather = testWeatherModel + weatherPreferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + weather = testWeatherModel, ) } } @@ -150,27 +107,16 @@ class WeatherEditScreenTest { } @Test - fun testPreviewButtonDisabledState() { - // Test when preview should be disabled (all options disabled) - val preferencesAllDisabled = WeatherPreferences( - showTitle = false, - showDescription = false, - showCurrentFee = false, - showNextBlockFee = false - ) - + fun testPreviewButtonDisabledWhenNothingSelected() { composeTestRule.setContent { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = {}, onClickPreview = {}, - weatherPreferences = preferencesAllDisabled, - weather = testWeatherModel + weatherPreferences = WeatherPreferences(selectedOption = null), + weather = testWeatherModel, ) } } @@ -179,162 +125,30 @@ class WeatherEditScreenTest { } @Test - fun testAllCallbacksTriggered() { - // Arrange - var titleClicked = false - var descriptionClicked = false - var currentFeeClicked = false - var nextBlockFeeClicked = false - var resetClicked = false - var previewClicked = false - - val customPreferences = WeatherPreferences( - showTitle = false, - showDescription = true, - showCurrentFee = false, - showNextBlockFee = true - ) - - composeTestRule.setContent { - AppThemeSurface { - WeatherEditContent( - onBack = {}, - onClickShowTitle = { titleClicked = true }, - onClickShowDescription = { descriptionClicked = true }, - onClickShowCurrentFee = { currentFeeClicked = true }, - onClickShowNextBlockFee = { nextBlockFeeClicked = true }, - onClickReset = { resetClicked = true }, - onClickPreview = { previewClicked = true }, - weatherPreferences = customPreferences, - weather = testWeatherModel - ) - } - } - - // Test all clickable elements - composeTestRule.onNodeWithTag("title_toggle_button").performClick() - assert(titleClicked) - - composeTestRule.onNodeWithTag("description_toggle_button").performClick() - assert(descriptionClicked) - - composeTestRule.onNodeWithTag("current_fee_toggle_button").performClick() - assert(currentFeeClicked) - - composeTestRule.onNodeWithTag("next_block_fee_toggle_button").performClick() - assert(nextBlockFeeClicked) - - composeTestRule.onNodeWithTag("WidgetEditPreview").performClick() - assert(previewClicked) - - composeTestRule.onNodeWithTag("WidgetEditReset").performClick() - assert(resetClicked) - } - - @Test - fun testEmptyValuesDisplay() { - // Arrange - Weather with empty values - val emptyWeather = WeatherModel( - title = R.string.widgets__weather__condition__good__title, - description = R.string.widgets__weather__condition__good__description, - currentFee = "", - nextBlockFee = "", - icon = FeeCondition.GOOD.icon - ) + fun testEachRowTriggersSelectionCallback() { + val captured = mutableListOf() composeTestRule.setContent { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = { captured.add(it) }, onClickReset = {}, onClickPreview = {}, - weatherPreferences = defaultPreferences, - weather = emptyWeather + weatherPreferences = WeatherPreferences(selectedOption = null), + weather = testWeatherModel, ) } } - // Assert that fee text elements don't exist when values are empty - listOf("current_fee", "next_block_fee").forEach { prefix -> - composeTestRule.onNodeWithTag("${prefix}_text").assertDoesNotExist() - } - } - - @Test - fun testAllElementsExist() { - // Arrange - composeTestRule.setContent { - AppThemeSurface { - WeatherEditContent( - onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, - onClickReset = {}, - onClickPreview = {}, - weatherPreferences = WeatherPreferences( - showTitle = false, - showDescription = true, - showCurrentFee = false, - showNextBlockFee = true - ), - weather = testWeatherModel - ) - } - } - - // Assert all tagged elements exist - composeTestRule.onNodeWithTag("weather_edit_screen").assertExists() - composeTestRule.onNodeWithTag("WidgetEditScrollView").assertExists() - composeTestRule.onNodeWithTag("edit_description").assertExists() - - listOf("title", "description", "current_fee", "next_block_fee").forEach { prefix -> - composeTestRule.onNodeWithTag("${prefix}_setting_row").assertExists() - composeTestRule.onNodeWithTag("${prefix}_toggle_button").assertExists() - composeTestRule.onNodeWithTag("${prefix}_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("${prefix}_divider").assertExists() - } - - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetEditReset").assertExists() - composeTestRule.onNodeWithTag("WidgetEditPreview").assertExists() - } - - @Test - fun testToggleIconsColorChange() { - // Arrange - val customPreferences = WeatherPreferences( - showTitle = true, - showDescription = false, - showCurrentFee = true, - showNextBlockFee = false - ) - - composeTestRule.setContent { - AppThemeSurface { - WeatherEditContent( - onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, - onClickReset = {}, - onClickPreview = {}, - weatherPreferences = customPreferences, - weather = testWeatherModel - ) - } - } + composeTestRule.onNodeWithTag("current_fee_fiat_toggle_button").performClick() + composeTestRule.onNodeWithTag("current_fee_sats_toggle_button").performClick() + composeTestRule.onNodeWithTag("next_block_toggle_button").performClick() - // Assert toggle icons have correct color based on preferences - composeTestRule.onNodeWithTag("title_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("description_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("current_fee_toggle_icon", useUnmergedTree = true).assertExists() - composeTestRule.onNodeWithTag("next_block_fee_toggle_icon", useUnmergedTree = true).assertExists() + assert(captured == listOf( + WeatherDataOption.CURRENT_FEE_FIAT, + WeatherDataOption.CURRENT_FEE_SATS, + WeatherDataOption.NEXT_BLOCK_INCLUSION, + )) } } diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt index be8dee9726..5804af8363 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreenTest.kt @@ -1,6 +1,5 @@ package to.bitkit.ui.screens.widgets.weather -import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick @@ -8,6 +7,7 @@ import org.junit.Rule import org.junit.Test import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface @@ -18,60 +18,49 @@ class WeatherPreviewContentTest { val composeTestRule = createComposeRule() private val testWeatherModel = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = FeeCondition.GOOD.icon + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, ) private val defaultPreferences = WeatherPreferences() @Test fun testWeatherPreviewWithEnabledWidget() { - // Arrange - var backClicked = false var editClicked = false var deleteClicked = false var saveClicked = false - // Act composeTestRule.setContent { AppThemeSurface { WeatherPreviewContent( - onBack = { backClicked = true }, + onBack = {}, onClickEdit = { editClicked = true }, onClickDelete = { deleteClicked = true }, onClickSave = { saveClicked = true }, - showWidgetTitles = true, isWeatherWidgetEnabled = true, weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel + weatherModel = testWeatherModel, ) } } - // Assert main elements exist composeTestRule.onNodeWithTag("weather_preview_screen").assertExists() - composeTestRule.onNodeWithTag("main_content").assertExists() - - // Verify header elements - composeTestRule.onNodeWithTag("header_row").assertExists() - composeTestRule.onNodeWithTag("widget_title").assertExists() - composeTestRule.onNodeWithTag("widget_icon").assertExists() composeTestRule.onNodeWithTag("widget_description").assertExists() - - // Verify settings and preview section + composeTestRule.onNodeWithTag("divider").assertExists() composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("preview_label").assertExists() - composeTestRule.onNodeWithTag("weather_card").assertExists() + composeTestRule.onNodeWithTag("weather_preview_carousel").assertExists() - // Verify buttons composeTestRule.onNodeWithTag("buttons_row").assertExists() composeTestRule.onNodeWithTag("WidgetDelete").assertExists() composeTestRule.onNodeWithTag("WidgetSave").assertExists() - // Test button clicks composeTestRule.onNodeWithTag("WidgetEdit").performClick() assert(editClicked) @@ -84,52 +73,36 @@ class WeatherPreviewContentTest { @Test fun testWeatherPreviewWithDisabledWidget() { - // Arrange - var backClicked = false - var editClicked = false - var deleteClicked = false var saveClicked = false - // Act composeTestRule.setContent { AppThemeSurface { WeatherPreviewContent( - onBack = { backClicked = true }, - onClickEdit = { editClicked = true }, - onClickDelete = { deleteClicked = true }, + onBack = {}, + onClickEdit = {}, + onClickDelete = {}, onClickSave = { saveClicked = true }, - showWidgetTitles = false, isWeatherWidgetEnabled = false, weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel + weatherModel = testWeatherModel, ) } } - // Assert main elements exist composeTestRule.onNodeWithTag("weather_preview_screen").assertExists() composeTestRule.onNodeWithTag("buttons_row").assertExists() - // Delete button should not exist when widget is disabled composeTestRule.onNodeWithTag("WidgetDelete").assertDoesNotExist() composeTestRule.onNodeWithTag("WidgetSave").assertExists() - // Test save button click composeTestRule.onNodeWithTag("WidgetSave").performClick() assert(saveClicked) } @Test fun testCustomWeatherPreferences() { - // Arrange - val customPreferences = WeatherPreferences( - showTitle = true, - showDescription = false, - showCurrentFee = true, - showNextBlockFee = false - ) + val customPreferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_SATS) - // Act composeTestRule.setContent { AppThemeSurface { WeatherPreviewContent( @@ -137,23 +110,20 @@ class WeatherPreviewContentTest { onClickEdit = {}, onClickDelete = {}, onClickSave = {}, - showWidgetTitles = true, isWeatherWidgetEnabled = true, weatherPreferences = customPreferences, - weatherModel = testWeatherModel + weatherModel = testWeatherModel, ) } } - // Assert that all elements still exist with custom preferences composeTestRule.onNodeWithTag("weather_preview_screen").assertExists() composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("weather_card").assertExists() + composeTestRule.onNodeWithTag("weather_preview_carousel").assertExists() } @Test - fun testAllElementsExist() { - // Arrange + fun testCarouselNotShownWhenWeatherModelIsNull() { composeTestRule.setContent { AppThemeSurface { WeatherPreviewContent( @@ -161,179 +131,14 @@ class WeatherPreviewContentTest { onClickEdit = {}, onClickDelete = {}, onClickSave = {}, - showWidgetTitles = true, - isWeatherWidgetEnabled = true, - weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel - ) - } - } - - // Assert all tagged elements exist - composeTestRule.onNodeWithTag("weather_preview_screen").assertExists() - composeTestRule.onNodeWithTag("main_content").assertExists() - composeTestRule.onNodeWithTag("header_row").assertExists() - composeTestRule.onNodeWithTag("widget_title").assertExists() - composeTestRule.onNodeWithTag("widget_icon").assertExists() - composeTestRule.onNodeWithTag("widget_description").assertExists() - composeTestRule.onNodeWithTag("divider").assertExists() - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - composeTestRule.onNodeWithTag("preview_label").assertExists() - composeTestRule.onNodeWithTag("weather_card").assertExists() - composeTestRule.onNodeWithTag("buttons_row").assertExists() - composeTestRule.onNodeWithTag("WidgetDelete").assertExists() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - } - - @Test - fun testNavigationCallbacks() { - // Arrange - var backClicked = false - - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = { backClicked = true }, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = true, isWeatherWidgetEnabled = true, weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel - ) - } - } - - // Note: Navigation callbacks are tested through the actual navigation components - } - - @Test - fun testWithMinimalWeatherPreferences() { - // Arrange - val minimalPreferences = WeatherPreferences( - showTitle = true, - showDescription = false, - showCurrentFee = false, - showNextBlockFee = false - ) - - // Act - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = false, - isWeatherWidgetEnabled = false, - weatherPreferences = minimalPreferences, - weatherModel = testWeatherModel + weatherModel = null, ) } } - // Assert core elements still exist composeTestRule.onNodeWithTag("weather_preview_screen").assertExists() - composeTestRule.onNodeWithTag("weather_card").assertExists() - composeTestRule.onNodeWithTag("WidgetSave").assertExists() - composeTestRule.onNodeWithTag("WidgetDelete").assertDoesNotExist() - } - - @Test - fun testWeatherCardVisibility() { - // Act - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = true, - isWeatherWidgetEnabled = true, - weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel - ) - } - } - - // Assert weather card is displayed with correct content - composeTestRule.onNodeWithTag("weather_card").assertIsDisplayed() - } - - @Test - fun testEditButtonShowsCustomState() { - // Arrange with custom preferences - val customPreferences = WeatherPreferences( - showTitle = true, - showDescription = false, - showCurrentFee = true, - showNextBlockFee = false - ) - - // Act - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = true, - isWeatherWidgetEnabled = true, - weatherPreferences = customPreferences, - weatherModel = testWeatherModel - ) - } - } - - // Assert edit button shows custom state - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() - } - - @Test - fun testNullWeatherModelCase() { - // Act - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = true, - isWeatherWidgetEnabled = true, - weatherPreferences = defaultPreferences, - weatherModel = null - ) - } - } - - // Assert weather card doesn't exist when weather model is null - composeTestRule.onNodeWithTag("weather_card").assertDoesNotExist() - } - - @Test - fun testEditButtonShowsDefaultState() { - // Act - composeTestRule.setContent { - AppThemeSurface { - WeatherPreviewContent( - onBack = {}, - onClickEdit = {}, - onClickDelete = {}, - onClickSave = {}, - showWidgetTitles = true, - isWeatherWidgetEnabled = true, - weatherPreferences = defaultPreferences, - weatherModel = testWeatherModel - ) - } - } - - // Assert edit button shows default state - composeTestRule.onNodeWithTag("WidgetEdit").assertExists() + composeTestRule.onNodeWithTag("weather_preview_carousel").assertDoesNotExist() } } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 561d6f50d2..6f6d293f7d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -243,6 +243,19 @@ android:resource="@xml/appwidget_info_facts" /> + + + + + + + + diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt index c45586a1d4..6581d188e4 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetDataRepository.kt @@ -4,12 +4,14 @@ import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.withContext import to.bitkit.data.dto.ArticleDTO import to.bitkit.data.dto.BlockDTO +import to.bitkit.data.dto.WeatherDTO import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.widgets.BlocksService import to.bitkit.data.widgets.FactsService import to.bitkit.data.widgets.NewsService import to.bitkit.data.widgets.PriceService +import to.bitkit.data.widgets.WeatherService import to.bitkit.di.IoDispatcher import javax.inject.Inject import javax.inject.Singleton @@ -21,6 +23,7 @@ class AppWidgetDataRepository @Inject constructor( private val newsService: NewsService, private val blocksService: BlocksService, private val factsService: FactsService, + private val weatherService: WeatherService, ) { suspend fun fetchPriceData(period: GraphPeriod = GraphPeriod.ONE_DAY): Result = withContext(ioDispatcher) { @@ -41,4 +44,9 @@ class AppWidgetDataRepository @Inject constructor( withContext(ioDispatcher) { factsService.fetchData() } + + suspend fun fetchWeather(): Result = + withContext(ioDispatcher) { + weatherService.fetchData() + } } diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt index fe3cd6700d..1bcb54cc03 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetPreferencesStore.kt @@ -15,6 +15,7 @@ import to.bitkit.appwidget.model.AppWidgetEntry import to.bitkit.appwidget.model.AppWidgetType import to.bitkit.data.dto.ArticleDTO import to.bitkit.data.dto.BlockDTO +import to.bitkit.data.dto.WeatherDTO import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO import to.bitkit.data.serializers.AppWidgetDataSerializer @@ -105,4 +106,8 @@ class AppWidgetPreferencesStore @Inject constructor( suspend fun bumpFactsRotationTick() { store.updateData { it.copy(factsRotationTick = it.factsRotationTick + 1) } } + + suspend fun cacheWeather(weather: WeatherDTO) { + store.updateData { it.copy(cachedWeather = weather) } + } } diff --git a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt index 6f39971397..20621a0cbe 100644 --- a/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt +++ b/app/src/main/java/to/bitkit/appwidget/AppWidgetRefreshWorker.kt @@ -24,6 +24,8 @@ import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceWidget import to.bitkit.appwidget.ui.price.PriceGlanceReceiver import to.bitkit.appwidget.ui.price.PriceGlanceWidget +import to.bitkit.appwidget.ui.weather.WeatherGlanceReceiver +import to.bitkit.appwidget.ui.weather.WeatherGlanceWidget import to.bitkit.utils.Logger import kotlin.time.Duration.Companion.minutes import kotlin.time.toJavaDuration @@ -71,6 +73,7 @@ class AppWidgetRefreshWorker @AssistedInject constructor( AppWidgetType.HEADLINES -> HeadlinesGlanceReceiver::class.java AppWidgetType.BLOCKS -> BlocksGlanceReceiver::class.java AppWidgetType.FACTS -> FactsGlanceReceiver::class.java + AppWidgetType.WEATHER -> WeatherGlanceReceiver::class.java } } @@ -121,6 +124,15 @@ class AppWidgetRefreshWorker @AssistedInject constructor( preferencesStore.bumpFactsRotationTick() FactsGlanceWidget().updateAll(appContext) } + + AppWidgetType.WEATHER -> { + dataRepository.fetchWeather() + .onSuccess { preferencesStore.cacheWeather(it) } + .onFailure { + Logger.warn("Failed to refresh weather", it, context = TAG) + } + WeatherGlanceWidget().updateAll(appContext) + } } } diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt index 7f8f3d66d0..fe322b9147 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigActivity.kt @@ -17,6 +17,8 @@ import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceReceiver import to.bitkit.appwidget.ui.headlines.HeadlinesGlanceWidget import to.bitkit.appwidget.ui.price.PriceGlanceReceiver import to.bitkit.appwidget.ui.price.PriceGlanceWidget +import to.bitkit.appwidget.ui.weather.WeatherGlanceReceiver +import to.bitkit.appwidget.ui.weather.WeatherGlanceWidget import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.utils.Logger @@ -59,6 +61,7 @@ class AppWidgetConfigActivity : ComponentActivity() { AppWidgetType.HEADLINES -> HeadlinesGlanceWidget().updateAll(this@AppWidgetConfigActivity) AppWidgetType.BLOCKS -> BlocksGlanceWidget().updateAll(this@AppWidgetConfigActivity) AppWidgetType.FACTS -> Unit + AppWidgetType.WEATHER -> WeatherGlanceWidget().updateAll(this@AppWidgetConfigActivity) } AppWidgetRefreshWorker.enqueue(this@AppWidgetConfigActivity) val result = Intent().putExtra( @@ -85,6 +88,7 @@ class AppWidgetConfigActivity : ComponentActivity() { HeadlinesGlanceReceiver::class.java.name -> AppWidgetType.HEADLINES PriceGlanceReceiver::class.java.name -> AppWidgetType.PRICE BlocksGlanceReceiver::class.java.name -> AppWidgetType.BLOCKS + WeatherGlanceReceiver::class.java.name -> AppWidgetType.WEATHER else -> { Logger.warn( "Encountered unknown provider class '$providerClass' " + diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt index 9ac538c5d6..718f965f50 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigScreen.kt @@ -47,5 +47,13 @@ fun AppWidgetConfigScreen( ) AppWidgetType.FACTS -> Unit + + AppWidgetType.WEATHER -> WeatherConfigContent( + state = state, + onSelectOption = { viewModel.selectWeatherOption(it) }, + onReset = { viewModel.resetPreferences() }, + onSave = { viewModel.saveAndFinish(onConfirm) }, + onCancel = onCancel, + ) } } diff --git a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt index d7942c0e8e..3a29d30f45 100644 --- a/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt +++ b/app/src/main/java/to/bitkit/appwidget/config/AppWidgetConfigViewModel.kt @@ -11,19 +11,26 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import to.bitkit.R import to.bitkit.appwidget.AppWidgetDataRepository import to.bitkit.appwidget.AppWidgetPreferencesStore import to.bitkit.appwidget.model.AppWidgetType import to.bitkit.appwidget.model.HomeBlocksPreferences import to.bitkit.appwidget.model.HomeHeadlinePreferences import to.bitkit.appwidget.model.HomePricePreferences +import to.bitkit.appwidget.model.HomeWeatherPreferences +import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.TradingPair import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences +import to.bitkit.models.widget.WeatherDataOption +import to.bitkit.models.widget.WeatherPreferences import to.bitkit.models.widget.toArticleModel +import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.blocks.toWeatherModel import to.bitkit.utils.Logger import javax.inject.Inject @@ -44,8 +51,9 @@ class AppWidgetConfigViewModel @Inject constructor( fun init(appWidgetId: Int, type: AppWidgetType) { viewModelScope.launch { val entry = preferencesStore.getEntry(appWidgetId) - val cachedArticles = preferencesStore.data.first().cachedArticles - val previewArticle = cachedArticles.randomOrNull()?.toArticleModel() ?: DEFAULT_PREVIEW_ARTICLE + val data = preferencesStore.data.first() + val previewArticle = data.cachedArticles.randomOrNull()?.toArticleModel() ?: DEFAULT_PREVIEW_ARTICLE + val previewWeather = data.cachedWeather?.toWeatherModel() ?: DEFAULT_PREVIEW_WEATHER _uiState.update { it.copy( @@ -54,9 +62,28 @@ class AppWidgetConfigViewModel @Inject constructor( pricePreferences = entry?.pricePreferences?.toInApp() ?: PricePreferences(), headlinePreferences = entry?.headlinePreferences?.toInApp() ?: HeadlinePreferences(), blocksPreferences = entry?.blocksPreferences?.toInApp() ?: BlocksPreferences(), + weatherPreferences = entry?.weatherPreferences?.toInApp() ?: WeatherPreferences(), previewArticle = previewArticle, + previewWeather = previewWeather, ) } + + if (type == AppWidgetType.WEATHER && data.cachedWeather == null) { + dataRepository.fetchWeather() + .onSuccess { fetched -> + preferencesStore.cacheWeather(fetched) + _uiState.update { it.copy(previewWeather = fetched.toWeatherModel()) } + } + .onFailure { Logger.warn("Failed to fetch weather for config preview", it, context = TAG) } + } + } + } + + fun selectWeatherOption(option: WeatherDataOption) { + _uiState.update { + val current = it.weatherPreferences.selectedOption + val next = if (current == option) null else option + it.copy(weatherPreferences = it.weatherPreferences.copy(selectedOption = next)) } } @@ -141,6 +168,7 @@ class AppWidgetConfigViewModel @Inject constructor( AppWidgetType.HEADLINES -> it.copy(headlinePreferences = HeadlinePreferences()) AppWidgetType.BLOCKS -> it.copy(blocksPreferences = BlocksPreferences()) AppWidgetType.FACTS -> it + AppWidgetType.WEATHER -> it.copy(weatherPreferences = WeatherPreferences()) } } } @@ -155,6 +183,7 @@ class AppWidgetConfigViewModel @Inject constructor( AppWidgetType.HEADLINES -> saveHeadlines(state) AppWidgetType.BLOCKS -> saveBlocks(state) AppWidgetType.FACTS -> Unit + AppWidgetType.WEATHER -> saveWeather(state) } onComplete() @@ -198,6 +227,18 @@ class AppWidgetConfigViewModel @Inject constructor( .onSuccess { preferencesStore.cacheBlock(it) } .onFailure { Logger.warn("Failed to fetch initial block", it, context = TAG) } } + + private suspend fun saveWeather(state: AppWidgetConfigUiState) { + val appWidgetId = state.appWidgetId + val weatherPreferences = state.weatherPreferences + preferencesStore.registerWidget(appWidgetId, AppWidgetType.WEATHER) + preferencesStore.updateEntry(appWidgetId) { entry -> + entry.copy(weatherPreferences = weatherPreferences.toHome()) + } + dataRepository.fetchWeather() + .onSuccess { preferencesStore.cacheWeather(it) } + .onFailure { Logger.warn("Failed to fetch initial weather", it, context = TAG) } + } } private val DEFAULT_PREVIEW_ARTICLE = ArticleModel( @@ -207,6 +248,18 @@ private val DEFAULT_PREVIEW_ARTICLE = ArticleModel( link = "", ) +private val DEFAULT_PREVIEW_WEATHER = WeatherModel( + condition = FeeCondition.GOOD, + title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, + description = R.string.widgets__weather__condition__good__description, + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, +) + @Stable data class AppWidgetConfigUiState( val appWidgetId: Int = -1, @@ -214,7 +267,9 @@ data class AppWidgetConfigUiState( val pricePreferences: PricePreferences = PricePreferences(), val headlinePreferences: HeadlinePreferences = HeadlinePreferences(), val blocksPreferences: BlocksPreferences = BlocksPreferences(), + val weatherPreferences: WeatherPreferences = WeatherPreferences(), val previewArticle: ArticleModel = DEFAULT_PREVIEW_ARTICLE, + val previewWeather: WeatherModel = DEFAULT_PREVIEW_WEATHER, val isSaving: Boolean = false, ) @@ -257,3 +312,7 @@ private fun BlocksPreferences.toHome() = HomeBlocksPreferences( showFees = showFees, showSource = showSource, ) + +private fun HomeWeatherPreferences.toInApp() = WeatherPreferences(selectedOption = selectedOption) + +private fun WeatherPreferences.toHome() = HomeWeatherPreferences(selectedOption = selectedOption) diff --git a/app/src/main/java/to/bitkit/appwidget/config/WeatherConfigContent.kt b/app/src/main/java/to/bitkit/appwidget/config/WeatherConfigContent.kt new file mode 100644 index 0000000000..85b9b3c0b6 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/config/WeatherConfigContent.kt @@ -0,0 +1,155 @@ +package to.bitkit.appwidget.config + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +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.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +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.widget.WeatherDataOption +import to.bitkit.models.widget.WeatherPreferences +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.AppTopBar +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.screens.widgets.weather.WeatherFeeValueText +import to.bitkit.ui.theme.Colors + +@Composable +internal fun WeatherConfigContent( + state: AppWidgetConfigUiState, + onSelectOption: (WeatherDataOption) -> Unit, + onReset: () -> Unit, + onSave: () -> Unit, + onCancel: () -> Unit, + modifier: Modifier = Modifier, +) { + val prefs = state.weatherPreferences + val weather = state.previewWeather + + ScreenColumn( + noBackground = true, + modifier = modifier.background(Colors.Gray7) + ) { + AppTopBar( + titleText = stringResource(R.string.widgets__weather__name), + onBackClick = onCancel, + ) + + Column( + modifier = Modifier + .padding(horizontal = 16.dp) + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + VerticalSpacer(16.dp) + + Caption13Up( + text = stringResource(R.string.widgets__widget__display), + color = Colors.White64, + modifier = Modifier.padding(bottom = 16.dp) + ) + + WeatherOptionRow( + label = stringResource(R.string.widgets__weather__current_fee), + value = weather.currentFee, + isSelected = prefs.selectedOption == WeatherDataOption.CURRENT_FEE_FIAT, + onClick = { onSelectOption(WeatherDataOption.CURRENT_FEE_FIAT) }, + ) + HorizontalDivider() + + WeatherOptionRow( + label = stringResource(R.string.widgets__weather__current_fee), + value = weather.currentFeeSatsFormatted, + isSelected = prefs.selectedOption == WeatherDataOption.CURRENT_FEE_SATS, + onClick = { onSelectOption(WeatherDataOption.CURRENT_FEE_SATS) }, + ) + HorizontalDivider() + + WeatherOptionRow( + label = stringResource(R.string.widgets__weather__next_block), + value = weather.nextBlockFee, + isSelected = prefs.selectedOption == WeatherDataOption.NEXT_BLOCK_INCLUSION, + onClick = { onSelectOption(WeatherDataOption.NEXT_BLOCK_INCLUSION) }, + ) + HorizontalDivider() + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) { + SecondaryButton( + text = stringResource(R.string.common__reset), + enabled = prefs != WeatherPreferences(), + fullWidth = false, + onClick = onReset, + modifier = Modifier.weight(1f) + ) + PrimaryButton( + text = stringResource(R.string.common__save), + isLoading = state.isSaving, + enabled = !state.isSaving && prefs.selectedOption != null, + fullWidth = false, + onClick = onSave, + modifier = Modifier.weight(1f) + ) + } + } +} + +@Composable +private fun WeatherOptionRow( + label: String, + value: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(vertical = 8.dp) + .fillMaxWidth() + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.weight(1f) + ) { + Caption13Up( + text = label, + color = Colors.White64, + ) + WeatherFeeValueText( + text = value, + color = Colors.Green, + ) + } + IconButton(onClick = onClick) { + Icon( + painter = painterResource(R.drawable.ic_checkmark), + contentDescription = null, + tint = if (isSelected) Colors.Brand else Colors.White50, + modifier = Modifier.size(32.dp) + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt index be4330d72a..1fffc48923 100644 --- a/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt +++ b/app/src/main/java/to/bitkit/appwidget/model/AppWidgetPreferences.kt @@ -4,15 +4,18 @@ import androidx.compose.runtime.Stable import kotlinx.serialization.Serializable import to.bitkit.data.dto.ArticleDTO import to.bitkit.data.dto.BlockDTO +import to.bitkit.data.dto.WeatherDTO 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.widget.WeatherDataOption enum class AppWidgetType { PRICE, HEADLINES, BLOCKS, FACTS, + WEATHER, } @Stable @@ -23,6 +26,7 @@ data class AppWidgetEntry( val pricePreferences: HomePricePreferences = HomePricePreferences(), val headlinePreferences: HomeHeadlinePreferences = HomeHeadlinePreferences(), val blocksPreferences: HomeBlocksPreferences = HomeBlocksPreferences(), + val weatherPreferences: HomeWeatherPreferences = HomeWeatherPreferences(), ) @Stable @@ -51,6 +55,12 @@ data class HomeBlocksPreferences( val showSource: Boolean = false, ) +@Stable +@Serializable +data class HomeWeatherPreferences( + val selectedOption: WeatherDataOption? = WeatherDataOption.CURRENT_FEE_FIAT, +) + @Stable @Serializable data class AppWidgetData( @@ -61,4 +71,5 @@ data class AppWidgetData( val cachedBlock: BlockDTO? = null, val cachedFacts: List = emptyList(), val factsRotationTick: Int = 0, + val cachedWeather: WeatherDTO? = null, ) diff --git a/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceContent.kt b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceContent.kt new file mode 100644 index 0000000000..63d1aab7a6 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceContent.kt @@ -0,0 +1,180 @@ +package to.bitkit.appwidget.ui.weather + +import android.appwidget.AppWidgetManager +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceModifier +import androidx.glance.LocalContext +import androidx.glance.LocalSize +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.HeightModifier +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.WidthModifier +import androidx.glance.layout.fillMaxHeight +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import androidx.glance.unit.Dimension +import to.bitkit.R +import to.bitkit.appwidget.config.AppWidgetConfigActivity +import to.bitkit.appwidget.model.AppWidgetEntry +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.model.HomeWeatherPreferences +import to.bitkit.appwidget.ui.components.BodySSB +import to.bitkit.appwidget.ui.components.CaptionB +import to.bitkit.appwidget.ui.components.GlanceLayoutDimens +import to.bitkit.appwidget.ui.components.GlanceWidgetScaffold +import to.bitkit.appwidget.ui.components.Subtitle +import to.bitkit.appwidget.ui.components.VerticalSpacer +import to.bitkit.appwidget.ui.theme.GlanceColors +import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption +import to.bitkit.models.widget.WeatherPreferences +import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.theme.Colors +import androidx.glance.unit.ColorProvider as UnitColorProvider + +private val LARGE_EMOJI_SIZE = 72.sp +private val SMALL_EMOJI_SIZE = 60.sp +private val FEE_VALUE_SIZE = 28.sp + +@Suppress("RestrictedApi") +@Composable +fun WeatherGlanceContent( + entry: AppWidgetEntry, + weather: WeatherModel?, +) { + val context = LocalContext.current + val configIntent = Intent(context, AppWidgetConfigActivity::class.java).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, entry.appWidgetId) + putExtra(AppWidgetConfigActivity.EXTRA_WIDGET_TYPE, AppWidgetType.WEATHER.name) + } + + GlanceWidgetScaffold(onClick = actionStartActivity(configIntent)) { + if (weather == null) { + CaptionB(text = context.getString(R.string.appwidget__loading)) + return@GlanceWidgetScaffold + } + + if (LocalSize.current.width >= GlanceLayoutDimens.WIDE_LAYOUT_MIN_WIDTH) { + WideContent(weather = weather, preferences = entry.weatherPreferences.toPreferences()) + } else { + CompactContent(weather = weather, preferences = entry.weatherPreferences.toPreferences()) + } + } +} + +@Suppress("RestrictedApi") +@Composable +private fun WideContent(weather: WeatherModel, preferences: WeatherPreferences) { + val context = LocalContext.current + Row( + verticalAlignment = Alignment.Top, + modifier = GlanceModifier.fillMaxSize() + ) { + Column( + modifier = GlanceModifier + .then(WidthModifier(Dimension.Expand)) + .fillMaxHeight() + ) { + Subtitle( + text = context.getString(weather.title), + maxLines = 1, + ) + VerticalSpacer(4.dp) + BodySSB( + text = context.getString(weather.description), + color = GlanceColors.textSecondary, + maxLines = 2, + ) + Spacer(modifier = GlanceModifier.then(HeightModifier(Dimension.Expand))) + FeeBlock(weather = weather, preferences = preferences) + } + WeatherEmoji(icon = weather.icon, fontSize = LARGE_EMOJI_SIZE) + } +} + +@Suppress("RestrictedApi") +@Composable +private fun CompactContent(weather: WeatherModel, preferences: WeatherPreferences) { + val context = LocalContext.current + Column(modifier = GlanceModifier.fillMaxSize()) { + WeatherEmoji(icon = weather.icon, fontSize = SMALL_EMOJI_SIZE) + VerticalSpacer(4.dp) + Subtitle( + text = context.getString(weather.shortTitle), + maxLines = 1, + ) + Spacer(modifier = GlanceModifier.then(HeightModifier(Dimension.Expand))) + FeeBlock(weather = weather, preferences = preferences) + } +} + +@Suppress("RestrictedApi") +@Composable +private fun FeeBlock(weather: WeatherModel, preferences: WeatherPreferences) { + val selected = preferences.selectedOption ?: return + val context = LocalContext.current + val (labelRes, value) = when (selected) { + WeatherDataOption.CURRENT_FEE_FIAT -> + R.string.widgets__weather__current_fee to weather.currentFee + WeatherDataOption.CURRENT_FEE_SATS -> + R.string.widgets__weather__current_fee to weather.currentFeeSatsFormatted + WeatherDataOption.NEXT_BLOCK_INCLUSION -> + R.string.widgets__weather__next_block to weather.nextBlockFee + } + + Column(modifier = GlanceModifier.fillMaxWidth()) { + CaptionB( + text = context.getString(labelRes), + color = GlanceColors.textSecondary, + maxLines = 1, + ) + VerticalSpacer(2.dp) + FeeValueText(text = value, color = weather.condition.feeColorProvider()) + } +} + +@Composable +private fun FeeValueText(text: String, color: UnitColorProvider) { + Text( + text = text, + style = TextStyle( + fontSize = FEE_VALUE_SIZE, + fontWeight = FontWeight.Bold, + color = color, + ), + maxLines = 1, + ) +} + +@Composable +private fun WeatherEmoji(icon: String, fontSize: TextUnit) { + Text( + text = icon, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = fontSize, + color = GlanceColors.textPrimary, + ), + ) +} + +private fun FeeCondition.feeColorProvider() = when (this) { + FeeCondition.GOOD -> ColorProvider(day = Colors.Green, night = Colors.Green) + FeeCondition.AVERAGE -> ColorProvider(day = Colors.Yellow, night = Colors.Yellow) + FeeCondition.POOR -> ColorProvider(day = Colors.Red, night = Colors.Red) +} + +private fun HomeWeatherPreferences.toPreferences(): WeatherPreferences = + WeatherPreferences(selectedOption = selectedOption) diff --git a/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt new file mode 100644 index 0000000000..210522f49a --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceReceiver.kt @@ -0,0 +1,40 @@ +package to.bitkit.appwidget.ui.weather + +import android.content.Context +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import dagger.hilt.android.EntryPointAccessors +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import to.bitkit.appwidget.AppWidgetEntryPoint +import to.bitkit.appwidget.AppWidgetRefreshWorker + +class WeatherGlanceReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = WeatherGlanceWidget() + + override fun onEnabled(context: Context) { + super.onEnabled(context) + AppWidgetRefreshWorker.enqueue(context) + } + + override fun onDeleted(context: Context, appWidgetIds: IntArray) { + super.onDeleted(context, appWidgetIds) + val pendingResult = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + val store = EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + .appWidgetPreferencesStore() + appWidgetIds.forEach { store.unregisterWidget(it) } + } finally { + pendingResult.finish() + } + } + } + + override fun onDisabled(context: Context) { + super.onDisabled(context) + AppWidgetRefreshWorker.cancelIfNoWidgets(context) + } +} diff --git a/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceWidget.kt b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceWidget.kt new file mode 100644 index 0000000000..793e563d95 --- /dev/null +++ b/app/src/main/java/to/bitkit/appwidget/ui/weather/WeatherGlanceWidget.kt @@ -0,0 +1,43 @@ +package to.bitkit.appwidget.ui.weather + +import android.content.Context +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.glance.GlanceId +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetManager +import androidx.glance.appwidget.SizeMode +import androidx.glance.appwidget.provideContent +import dagger.hilt.android.EntryPointAccessors +import to.bitkit.appwidget.AppWidgetEntryPoint +import to.bitkit.appwidget.model.AppWidgetData +import to.bitkit.appwidget.model.AppWidgetEntry +import to.bitkit.appwidget.model.AppWidgetType +import to.bitkit.appwidget.ui.components.GlanceLayoutDimens +import to.bitkit.ui.screens.widgets.blocks.toWeatherModel + +class WeatherGlanceWidget : GlanceAppWidget() { + + override val sizeMode = SizeMode.Responsive( + setOf(GlanceLayoutDimens.COMPACT_WIDGET_SIZE, GlanceLayoutDimens.WIDE_WIDGET_SIZE), + ) + + override suspend fun provideGlance(context: Context, id: GlanceId) { + val store = EntryPointAccessors + .fromApplication(context, AppWidgetEntryPoint::class.java) + .appWidgetPreferencesStore() + val appWidgetId = GlanceAppWidgetManager(context).getAppWidgetId(id) + + provideContent { + val data by store.data.collectAsState(initial = AppWidgetData()) + val entry = data.entries.find { it.appWidgetId == appWidgetId } + ?: AppWidgetEntry(appWidgetId = appWidgetId, type = AppWidgetType.WEATHER) + val weather = data.cachedWeather?.toWeatherModel() + + WeatherGlanceContent( + entry = entry, + weather = weather, + ) + } + } +} diff --git a/app/src/main/java/to/bitkit/data/dto/WeatherDTO.kt b/app/src/main/java/to/bitkit/data/dto/WeatherDTO.kt index 79c9812c25..1f3660736b 100644 --- a/app/src/main/java/to/bitkit/data/dto/WeatherDTO.kt +++ b/app/src/main/java/to/bitkit/data/dto/WeatherDTO.kt @@ -7,5 +7,6 @@ import kotlinx.serialization.Serializable data class WeatherDTO( val condition: FeeCondition, val currentFee: String, - val nextBlockFee: Int + val nextBlockFee: Int, + val avgFeeSats: Long = 0L, ) diff --git a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt index dda33e962d..d237dd2c29 100644 --- a/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt +++ b/app/src/main/java/to/bitkit/data/widgets/WeatherService.kt @@ -77,6 +77,7 @@ class WeatherService @Inject constructor( condition = condition, currentFee = currentFee, nextBlockFee = feeEstimates.fast, + avgFeeSats = avgFeeSats, ) }.onFailure { Logger.warn("Failed to fetch weather data", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/models/widget/WeatherPreferences.kt b/app/src/main/java/to/bitkit/models/widget/WeatherPreferences.kt index 1151c37a6a..37a374d1b9 100644 --- a/app/src/main/java/to/bitkit/models/widget/WeatherPreferences.kt +++ b/app/src/main/java/to/bitkit/models/widget/WeatherPreferences.kt @@ -3,11 +3,15 @@ package to.bitkit.models.widget import androidx.compose.runtime.Immutable import kotlinx.serialization.Serializable +@Serializable +enum class WeatherDataOption { + CURRENT_FEE_FIAT, + CURRENT_FEE_SATS, + NEXT_BLOCK_INCLUSION, +} + @Immutable @Serializable data class WeatherPreferences( - val showTitle: Boolean = true, - val showDescription: Boolean = false, - val showCurrentFee: Boolean = false, - val showNextBlockFee: Boolean = false, + val selectedOption: WeatherDataOption? = WeatherDataOption.CURRENT_FEE_FIAT, ) diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 4536458167..e0c79046cc 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -60,6 +60,7 @@ import to.bitkit.models.toSettingsString import to.bitkit.models.widget.BlocksPreferences import to.bitkit.models.widget.HeadlinePreferences import to.bitkit.models.widget.PricePreferences +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.repositories.ActivityRepo import to.bitkit.services.core.Bip39Service @@ -1138,19 +1139,18 @@ class MigrationService @Inject constructor( val weatherJson = json.decodeFromString( weatherData.decodeToString() ) - val showTitle = weatherJson["showStatus"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: true - val showDescription = weatherJson["showText"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false val showCurrentFee = weatherJson["showMedian"]?.jsonPrimitive?.content?.toBooleanStrictOrNull() ?: false val showNextBlockFee = weatherJson["showNextBlockFee"]?.jsonPrimitive?.content ?.toBooleanStrictOrNull() ?: false + val selectedOption = when { + showCurrentFee -> WeatherDataOption.CURRENT_FEE_FIAT + showNextBlockFee -> WeatherDataOption.NEXT_BLOCK_INCLUSION + else -> WeatherDataOption.CURRENT_FEE_FIAT + } + widgetsStore.updateWeatherPreferences( - WeatherPreferences( - showTitle = showTitle, - showDescription = showDescription, - showCurrentFee = showCurrentFee, - showNextBlockFee = showNextBlockFee - ) + WeatherPreferences(selectedOption = selectedOption) ) }.onFailure { Logger.error("Failed to migrate weather preferences: $it", it, context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/components/Text.kt b/app/src/main/java/to/bitkit/ui/components/Text.kt index 94aaa9340f..9ea91f9378 100644 --- a/app/src/main/java/to/bitkit/ui/components/Text.kt +++ b/app/src/main/java/to/bitkit/ui/components/Text.kt @@ -453,6 +453,8 @@ fun Caption13Up( modifier: Modifier = Modifier, color: Color = MaterialTheme.colorScheme.primary, textAlign: TextAlign = TextAlign.Start, + maxLines: Int = Int.MAX_VALUE, + overflow: TextOverflow = TextOverflow.Clip, ) { Text( text = text.uppercase(), @@ -460,6 +462,8 @@ fun Caption13Up( color = color, textAlign = textAlign, ), + maxLines = maxLines, + overflow = overflow, modifier = modifier ) } 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 ca07c97b32..3be2090615 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 @@ -83,6 +83,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch import to.bitkit.R +import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.price.Change import to.bitkit.data.dto.price.GraphPeriod import to.bitkit.data.dto.price.PriceDTO @@ -802,7 +803,6 @@ private fun Widgets( WidgetType.WEATHER -> { homeUiState.currentWeather?.run { WeatherCard( - showWidgetTitle = homeUiState.showWidgetTitles, weatherModel = this, preferences = homeUiState.weatherPreferences, modifier = Modifier.fillMaxWidth() @@ -1000,10 +1000,14 @@ private val previewPrice = PriceDTO( ) private val previewWeather = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 \u20BF", + nextBlockFee = "6 \u20BF/vByte", icon = "\u2600\uFE0F", ) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/WeatherModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/WeatherModel.kt index e3ba8293a4..607245f2da 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/WeatherModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/WeatherModel.kt @@ -5,22 +5,33 @@ import androidx.compose.runtime.Immutable import to.bitkit.R import to.bitkit.data.dto.FeeCondition import to.bitkit.data.dto.WeatherDTO +import java.text.NumberFormat +import java.util.Locale @Immutable data class WeatherModel( + val condition: FeeCondition, @StringRes val title: Int, + @StringRes val shortTitle: Int, @StringRes val description: Int, val currentFee: String, + val currentFeeSats: Long, + val currentFeeSatsFormatted: String, val nextBlockFee: String, val icon: String, ) -fun WeatherDTO.toWeatherModel(): WeatherModel { +fun WeatherDTO.toWeatherModel(locale: Locale = Locale.getDefault()): WeatherModel { val title = when (condition) { FeeCondition.GOOD -> R.string.widgets__weather__condition__good__title FeeCondition.AVERAGE -> R.string.widgets__weather__condition__average__title FeeCondition.POOR -> R.string.widgets__weather__condition__poor__title } + val shortTitle = when (condition) { + FeeCondition.GOOD -> R.string.widgets__weather__condition__good__short_title + FeeCondition.AVERAGE -> R.string.widgets__weather__condition__average__short_title + FeeCondition.POOR -> R.string.widgets__weather__condition__poor__short_title + } val description = when (condition) { FeeCondition.GOOD -> R.string.widgets__weather__condition__good__description FeeCondition.AVERAGE -> R.string.widgets__weather__condition__average__description @@ -28,9 +39,13 @@ fun WeatherDTO.toWeatherModel(): WeatherModel { } return WeatherModel( + condition = condition, title = title, + shortTitle = shortTitle, description = description, currentFee = currentFee, + currentFeeSats = avgFeeSats, + currentFeeSatsFormatted = "${NumberFormat.getInstance(locale).format(avgFeeSats)} ₿", nextBlockFee = "$nextBlockFee ₿/vByte", icon = condition.icon, ) 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 82eed89a55..1f7d114ef9 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 @@ -1,16 +1,15 @@ package to.bitkit.ui.screens.widgets.weather +import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -19,216 +18,330 @@ 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.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences -import to.bitkit.ui.components.BodyMSB -import to.bitkit.ui.components.BodySB +import to.bitkit.ui.components.BodyS +import to.bitkit.ui.components.Caption13Up import to.bitkit.ui.components.Subtitle import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.theme.InterFontFamily @Composable fun WeatherCard( modifier: Modifier = Modifier, - showWidgetTitle: Boolean, weatherModel: WeatherModel, preferences: WeatherPreferences, ) { Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(Colors.Gray6) + .testTag("weather_card") ) { - Column( + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, modifier = Modifier .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { - if (showWidgetTitle) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(bottom = 8.dp) - .testTag("weather_card_widget_title_row") - ) { - Icon( - painter = painterResource(R.drawable.widget_cloud), - contentDescription = null, - modifier = Modifier - .size(32.dp) - .testTag("weather_card_condition_icon"), - tint = Color.Unspecified - ) - Spacer(modifier = Modifier.width(16.dp)) - BodyMSB( - text = stringResource(R.string.widgets__weather__name), - modifier = Modifier.testTag("weather_card_widget_title_text") - ) - } + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .weight(1f) + .testTag("weather_card_content") + ) { + WeatherTitleBlock( + titleRes = weatherModel.title, + descriptionRes = weatherModel.description, + showDescription = true, + ) + WeatherFeeBlock( + weatherModel = weatherModel, + selectedOption = preferences.selectedOption, + ) } - if (preferences.showTitle) { - Row( - modifier = Modifier - .fillMaxWidth() - .testTag("weather_card_title_row"), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(weatherModel.title).uppercase(), - overflow = TextOverflow.Ellipsis, - maxLines = 2, - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 34.sp, - lineHeight = 34.sp, - letterSpacing = 0.sp, - fontFamily = InterFontFamily, - color = Colors.White, - ), - modifier = Modifier.weight(1f), - ) - - Text( - text = weatherModel.icon, - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 100.sp, - lineHeight = 80.sp, - letterSpacing = 0.sp, - fontFamily = InterFontFamily, - color = Colors.White, - ), - ) - } - } + WeatherEmoji( + icon = weatherModel.icon, + fontSize = LARGE_EMOJI_SIZE, + ) + } + } +} - if (preferences.showDescription) { +@Composable +fun WeatherCardSmall( + modifier: Modifier = Modifier, + weatherModel: WeatherModel, + preferences: WeatherPreferences, +) { + Box( + modifier = modifier + .size(WidgetCardDimens.COMPACT_CARD_SIZE) + .clip(shape = MaterialTheme.shapes.medium) + .background(Colors.Gray6) + .testTag("weather_card_small") + ) { + Column( + verticalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = Modifier.testTag("weather_card_small_header") + ) { + WeatherEmoji( + icon = weatherModel.icon, + fontSize = SMALL_EMOJI_SIZE, + ) Subtitle( - text = stringResource(weatherModel.description), + text = stringResource(weatherModel.shortTitle), color = Colors.White, - modifier = Modifier - .padding(top = 8.dp) - .testTag("weather_card_description_text") + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("weather_card_small_title") ) } - if (preferences.showCurrentFee) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp) - .testTag("weather_card_current_fee_row"), - horizontalArrangement = Arrangement.SpaceBetween - ) { - BodySB( - text = stringResource(R.string.widgets__weather__current_fee), - color = Colors.White64, - modifier = Modifier.testTag("weather_card_current_fee_label") - ) - BodySB( - text = weatherModel.currentFee, - color = Colors.White, - modifier = Modifier.testTag("weather_card_current_fee_value"), - ) - } - } + WeatherFeeBlock( + weatherModel = weatherModel, + selectedOption = preferences.selectedOption, + ) + } + } +} - if (preferences.showNextBlockFee) { - Row( - modifier = Modifier - .fillMaxWidth() - .testTag("weather_card_next_block_row"), - horizontalArrangement = Arrangement.SpaceBetween - ) { - BodySB( - text = stringResource(R.string.widgets__weather__next_block), - color = Colors.White64, - modifier = Modifier.testTag("weather_card_next_block_fee_label") - ) - BodySB( - text = weatherModel.nextBlockFee, - color = Colors.White, - modifier = Modifier.testTag("weather_card_next_block_fee_value"), - ) - } - } +@Composable +private fun WeatherTitleBlock( + modifier: Modifier = Modifier, + @StringRes titleRes: Int, + @StringRes descriptionRes: Int, + showDescription: Boolean, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .fillMaxWidth() + .testTag("weather_card_title_block") + ) { + Subtitle( + text = stringResource(titleRes), + color = Colors.White, + modifier = Modifier.testTag("weather_card_title") + ) + if (showDescription) { + BodyS( + text = stringResource(descriptionRes), + color = Colors.White80, + modifier = Modifier.testTag("weather_card_description") + ) } } } +@Composable +private fun WeatherFeeBlock( + modifier: Modifier = Modifier, + weatherModel: WeatherModel, + selectedOption: WeatherDataOption?, +) { + selectedOption ?: return + + val (labelRes, value, testTagPrefix) = when (selectedOption) { + WeatherDataOption.CURRENT_FEE_FIAT -> + Triple(R.string.widgets__weather__current_fee, weatherModel.currentFee, "current_fee_fiat") + WeatherDataOption.CURRENT_FEE_SATS -> + Triple( + R.string.widgets__weather__current_fee, + weatherModel.currentFeeSatsFormatted, + "current_fee_sats", + ) + WeatherDataOption.NEXT_BLOCK_INCLUSION -> + Triple(R.string.widgets__weather__next_block, weatherModel.nextBlockFee, "next_block") + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .fillMaxWidth() + .testTag("weather_card_${testTagPrefix}_block") + ) { + Caption13Up( + text = stringResource(labelRes), + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.testTag("weather_card_${testTagPrefix}_label") + ) + WeatherFeeValueText( + text = value, + color = weatherModel.condition.feeColor(), + modifier = Modifier.testTag("weather_card_${testTagPrefix}_value") + ) + } +} + +@Composable +private fun WeatherEmoji( + modifier: Modifier = Modifier, + icon: String, + fontSize: TextUnit, +) { + Text( + text = icon, + style = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = fontSize, + lineHeight = fontSize, + color = Colors.White, + ), + modifier = modifier.testTag("weather_card_icon") + ) +} + +private fun FeeCondition.feeColor(): Color = when (this) { + FeeCondition.GOOD -> Colors.Green + FeeCondition.AVERAGE -> Colors.Yellow + FeeCondition.POOR -> Colors.Red +} + +private val LARGE_EMOJI_SIZE = 82.sp +private val SMALL_EMOJI_SIZE = 60.sp + @Preview(showBackground = true) @Composable -private fun WeatherCardPreview() { +private fun PreviewLarge() { AppThemeSurface { Column( - modifier = Modifier - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp) ) { WeatherCard( - showWidgetTitle = true, weatherModel = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = FeeCondition.GOOD.icon + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, ), - preferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = true - ) + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + modifier = Modifier.fillMaxWidth() ) WeatherCard( - showWidgetTitle = false, weatherModel = WeatherModel( + condition = FeeCondition.AVERAGE, title = R.string.widgets__weather__condition__average__title, + shortTitle = R.string.widgets__weather__condition__average__short_title, description = R.string.widgets__weather__condition__average__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = FeeCondition.AVERAGE.icon + currentFee = "$ 1.27", + currentFeeSats = 1270L, + currentFeeSatsFormatted = "1,270 ₿", + nextBlockFee = "12 ₿/vByte", + icon = FeeCondition.AVERAGE.icon, ), - preferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = false - ) + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + modifier = Modifier.fillMaxWidth() ) WeatherCard( - showWidgetTitle = false, weatherModel = WeatherModel( + condition = FeeCondition.POOR, title = R.string.widgets__weather__condition__poor__title, + shortTitle = R.string.widgets__weather__condition__poor__short_title, description = R.string.widgets__weather__condition__poor__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = FeeCondition.POOR.icon + currentFee = "$ 4.50", + currentFeeSats = 4500L, + currentFeeSatsFormatted = "4,500 ₿", + nextBlockFee = "45 ₿/vByte", + icon = FeeCondition.POOR.icon, ), - preferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = false - ) + preferences = WeatherPreferences(selectedOption = WeatherDataOption.NEXT_BLOCK_INCLUSION), + modifier = Modifier.fillMaxWidth() + ) + } + } +} + +@Preview(showBackground = true) +@Composable +private fun PreviewSmall() { + AppThemeSurface { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(16.dp) + ) { + WeatherCardSmall( + weatherModel = WeatherModel( + condition = FeeCondition.GOOD, + title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, + description = R.string.widgets__weather__condition__good__description, + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, + ), + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), + ) + WeatherCardSmall( + weatherModel = WeatherModel( + condition = FeeCondition.AVERAGE, + title = R.string.widgets__weather__condition__average__title, + shortTitle = R.string.widgets__weather__condition__average__short_title, + description = R.string.widgets__weather__condition__average__description, + currentFee = "$ 1.27", + currentFeeSats = 1270L, + currentFeeSatsFormatted = "1,270 ₿", + nextBlockFee = "12 ₿/vByte", + icon = FeeCondition.AVERAGE.icon, + ), + preferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_FIAT), ) } } } + +@Preview(showBackground = true) +@Composable +private fun PreviewLargeNoSelection() { + AppThemeSurface { + WeatherCard( + weatherModel = WeatherModel( + condition = FeeCondition.GOOD, + title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, + description = R.string.widgets__weather__condition__good__description, + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, + ), + preferences = WeatherPreferences(selectedOption = null), + modifier = Modifier + .padding(16.dp) + .fillMaxWidth() + ) + } +} diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreen.kt index ac545d2c05..f9e9cd4bee 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherEditScreen.kt @@ -1,19 +1,15 @@ package to.bitkit.ui.screens.widgets.weather +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment @@ -21,33 +17,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.text.font.FontWeight -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.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import to.bitkit.R import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.PrimaryDisplay +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences -import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.BodySSB +import to.bitkit.ui.components.Caption13Up +import to.bitkit.ui.components.FillHeight import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.components.rememberMoneyText import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors -import to.bitkit.ui.theme.InterFontFamily @Composable fun WeatherEditScreen( weatherViewModel: WeatherViewModel, onBack: () -> Unit, navigatePreview: () -> Unit, + modifier: Modifier = Modifier, ) { val customPreferences by weatherViewModel.customPreferences.collectAsStateWithLifecycle() val currentWeather by weatherViewModel.currentWeather.collectAsStateWithLifecycle() @@ -55,13 +50,11 @@ fun WeatherEditScreen( WeatherEditContent( onBack = onBack, weatherPreferences = customPreferences, - onClickShowTitle = { weatherViewModel.toggleShowTitle() }, - onClickShowDescription = { weatherViewModel.toggleShowDescription() }, - onClickShowCurrentFee = { weatherViewModel.toggleShowCurrentFee() }, - onClickShowNextBlockFee = { weatherViewModel.toggleShowNextBlockFee() }, + weather = currentWeather, + onSelectOption = { weatherViewModel.selectOption(it) }, onClickReset = { weatherViewModel.resetCustomPreferences() }, onClickPreview = navigatePreview, - weather = currentWeather + modifier = modifier ) } @@ -69,217 +62,146 @@ fun WeatherEditScreen( fun WeatherEditContent( onBack: () -> Unit, weather: WeatherModel?, - onClickShowTitle: () -> Unit, - onClickShowDescription: () -> Unit, - onClickShowCurrentFee: () -> Unit, - onClickShowNextBlockFee: () -> Unit, + onSelectOption: (WeatherDataOption) -> Unit, onClickReset: () -> Unit, onClickPreview: () -> Unit, weatherPreferences: WeatherPreferences, + modifier: Modifier = Modifier, ) { ScreenColumn( - modifier = Modifier.testTag("weather_edit_screen") + noBackground = true, + modifier = modifier + .background(Colors.Gray7) + .testTag("weather_edit_screen") ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__edit), + titleText = stringResource(R.string.widgets__weather__name), onBackClick = onBack, - actions = { DrawerNavIcon() }, ) Column( modifier = Modifier .padding(horizontal = 16.dp) - .weight(1f) - .verticalScroll(rememberScrollState()) .testTag("WidgetEditScrollView") ) { - Spacer(modifier = Modifier.height(26.dp)) + VerticalSpacer(16.dp) - BodyM( - text = stringResource(R.string.widgets__widget__edit_description).replace( - "{name}", - stringResource(R.string.widgets__weather__name) - ), + Caption13Up( + text = stringResource(R.string.widgets__widget__display), color = Colors.White64, - modifier = Modifier.testTag("edit_description") + modifier = Modifier + .padding(bottom = 16.dp) + .testTag("display_section_header") + ) + + WeatherEditOptionRow( + label = stringResource(R.string.widgets__weather__current_fee), + value = rememberWeatherOptionValue(WeatherDataOption.CURRENT_FEE_FIAT, weather), + isSelected = weatherPreferences.selectedOption == WeatherDataOption.CURRENT_FEE_FIAT, + onClick = { onSelectOption(WeatherDataOption.CURRENT_FEE_FIAT) }, + testTagPrefix = "current_fee_fiat", ) - Spacer(modifier = Modifier.height(32.dp)) + WeatherEditOptionRow( + label = stringResource(R.string.widgets__weather__current_fee), + value = rememberWeatherOptionValue(WeatherDataOption.CURRENT_FEE_SATS, weather), + isSelected = weatherPreferences.selectedOption == WeatherDataOption.CURRENT_FEE_SATS, + onClick = { onSelectOption(WeatherDataOption.CURRENT_FEE_SATS) }, + testTagPrefix = "current_fee_sats", + ) + + WeatherEditOptionRow( + label = stringResource(R.string.widgets__weather__next_block), + value = rememberWeatherOptionValue(WeatherDataOption.NEXT_BLOCK_INCLUSION, weather), + isSelected = weatherPreferences.selectedOption == WeatherDataOption.NEXT_BLOCK_INCLUSION, + onClick = { onSelectOption(WeatherDataOption.NEXT_BLOCK_INCLUSION) }, + testTagPrefix = "next_block", + ) + + FillHeight() Row( horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically, modifier = Modifier .padding(vertical = 21.dp) .fillMaxWidth() - .testTag("title_setting_row") + .testTag("buttons_row") ) { - Text( - text = weather?.title?.let { stringResource(it) }.orEmpty(), - overflow = TextOverflow.Ellipsis, - maxLines = 2, - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 34.sp, - lineHeight = 34.sp, - letterSpacing = 0.sp, - fontFamily = InterFontFamily, - color = Colors.White, - ), + SecondaryButton( + text = stringResource(R.string.common__reset), + enabled = weatherPreferences != WeatherPreferences(), + fullWidth = false, + onClick = onClickReset, modifier = Modifier .weight(1f) - .testTag("title_text"), + .testTag("WidgetEditReset") ) - weather?.icon?.let { - Text( - text = it, - style = TextStyle( - fontWeight = FontWeight.Bold, - fontSize = 100.sp, - lineHeight = 80.sp, - letterSpacing = 0.sp, - fontFamily = InterFontFamily, - color = Colors.White, - ), - ) - } - - IconButton( - onClick = onClickShowTitle, - modifier = Modifier.testTag("title_toggle_button") - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark), - contentDescription = null, - tint = if (weatherPreferences.showTitle) Colors.Brand else Colors.White50, - modifier = Modifier - .size(32.dp) - .testTag("title_toggle_icon"), - ) - } - } - - HorizontalDivider( - modifier = Modifier.testTag("title_divider") - ) - - Row( - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(vertical = 21.dp) - .fillMaxWidth() - .testTag("description_setting_row") - ) { - BodyM( - text = weather?.description?.let { stringResource(it) }.orEmpty(), - color = Colors.White, + PrimaryButton( + text = stringResource(R.string.common__preview), + enabled = weatherPreferences.selectedOption != null, + fullWidth = false, + onClick = onClickPreview, modifier = Modifier .weight(1f) - .testTag("description_text") + .testTag("WidgetEditPreview") ) - - IconButton( - onClick = onClickShowDescription, - modifier = Modifier.testTag("description_toggle_button") - ) { - Icon( - painter = painterResource(R.drawable.ic_checkmark), - contentDescription = null, - tint = if (weatherPreferences.showDescription) Colors.Brand else Colors.White50, - modifier = Modifier - .size(32.dp) - .testTag("description_toggle_icon"), - ) - } } - - HorizontalDivider( - modifier = Modifier.testTag("description_divider") - ) - - // Current fee toggle - WeatherEditOptionRow( - label = stringResource(R.string.widgets__weather__current_fee), - value = weather?.currentFee.orEmpty(), - isEnabled = weatherPreferences.showCurrentFee, - onClick = onClickShowCurrentFee, - testTagPrefix = "current_fee" - ) - - // Next block fee toggle - WeatherEditOptionRow( - label = stringResource(R.string.widgets__weather__next_block), - value = weather?.nextBlockFee.orEmpty(), - isEnabled = weatherPreferences.showNextBlockFee, - onClick = onClickShowNextBlockFee, - testTagPrefix = "next_block_fee" - ) - } - - Row( - modifier = Modifier - .padding(vertical = 21.dp, horizontal = 16.dp) - .fillMaxWidth() - .testTag("buttons_row"), - horizontalArrangement = Arrangement.spacedBy(16.dp) - ) { - SecondaryButton( - text = stringResource(R.string.common__reset), - modifier = Modifier - .weight(1f) - .testTag("WidgetEditReset"), - enabled = weatherPreferences != WeatherPreferences(), - fullWidth = false, - onClick = onClickReset - ) - - PrimaryButton( - text = stringResource(R.string.common__preview), - enabled = weatherPreferences.run { - showTitle || showDescription || showCurrentFee || showNextBlockFee - }, - modifier = Modifier - .weight(1f) - .testTag("WidgetEditPreview"), - fullWidth = false, - onClick = onClickPreview - ) } } } +@Composable +private fun rememberWeatherOptionValue( + option: WeatherDataOption, + weather: WeatherModel?, +): String = when (option) { + WeatherDataOption.CURRENT_FEE_FIAT -> weather?.currentFee.orEmpty() + WeatherDataOption.CURRENT_FEE_SATS -> rememberMoneyText( + sats = weather?.currentFeeSats ?: 0L, + unit = PrimaryDisplay.BITCOIN, + showSymbol = true, + ).orEmpty().stripAccentTags() + WeatherDataOption.NEXT_BLOCK_INCLUSION -> weather?.nextBlockFee.orEmpty() +} + +private fun String.stripAccentTags(): String = + replace("", "").replace("", "") + @Composable private fun WeatherEditOptionRow( label: String, value: String, - isEnabled: Boolean, + isSelected: Boolean, onClick: () -> Unit, testTagPrefix: String, + modifier: Modifier = Modifier, ) { - Column { + Column(modifier = modifier) { Row( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .padding(vertical = 21.dp) + .padding(vertical = 8.dp) .fillMaxWidth() .testTag("${testTagPrefix}_setting_row") ) { - BodySSB( - text = label, - color = Colors.White64, + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .weight(1f) - .testTag("${testTagPrefix}_label") - ) + .testTag("${testTagPrefix}_value_column") + ) { + Caption13Up( + text = label, + color = Colors.White64, + modifier = Modifier.testTag("${testTagPrefix}_label") + ) - if (value.isNotEmpty()) { - BodySSB( + WeatherFeeValueText( text = value, - color = Colors.White, - modifier = Modifier.testTag("${testTagPrefix}_text") + color = Colors.Green, + modifier = Modifier.testTag("${testTagPrefix}_value") ) } @@ -290,10 +212,10 @@ private fun WeatherEditOptionRow( Icon( painter = painterResource(R.drawable.ic_checkmark), contentDescription = null, - tint = if (isEnabled) Colors.Brand else Colors.White50, + tint = if (isSelected) Colors.Brand else Colors.White50, modifier = Modifier .size(32.dp) - .testTag("${testTagPrefix}_toggle_icon"), + .testTag("${testTagPrefix}_toggle_icon") ) } } @@ -310,19 +232,20 @@ private fun Preview() { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = {}, onClickPreview = {}, weatherPreferences = WeatherPreferences(), weather = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = FeeCondition.GOOD.icon + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = FeeCondition.GOOD.icon, ), ) } @@ -330,28 +253,24 @@ private fun Preview() { @Preview(showBackground = true) @Composable -private fun PreviewWithSomeOptionsEnabled() { +private fun PreviewSelectedSats() { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = {}, onClickPreview = {}, - weatherPreferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = false - ), + weatherPreferences = WeatherPreferences(selectedOption = WeatherDataOption.CURRENT_FEE_SATS), weather = WeatherModel( + condition = FeeCondition.AVERAGE, title = R.string.widgets__weather__condition__average__title, + shortTitle = R.string.widgets__weather__condition__average__short_title, description = R.string.widgets__weather__condition__average__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = FeeCondition.AVERAGE.icon + currentFee = "$ 1.20", + currentFeeSats = 1200L, + currentFeeSatsFormatted = "1,200 ₿", + nextBlockFee = "12 ₿/vByte", + icon = FeeCondition.AVERAGE.icon, ), ) } @@ -359,28 +278,24 @@ private fun PreviewWithSomeOptionsEnabled() { @Preview(showBackground = true) @Composable -private fun PreviewWithAllDisabled() { +private fun PreviewNoneSelected() { AppThemeSurface { WeatherEditContent( onBack = {}, - onClickShowTitle = {}, - onClickShowDescription = {}, - onClickShowCurrentFee = {}, - onClickShowNextBlockFee = {}, + onSelectOption = {}, onClickReset = {}, onClickPreview = {}, - weatherPreferences = WeatherPreferences( - showTitle = false, - showDescription = false, - showCurrentFee = false, - showNextBlockFee = false - ), + weatherPreferences = WeatherPreferences(selectedOption = null), weather = WeatherModel( + condition = FeeCondition.POOR, title = R.string.widgets__weather__condition__poor__title, + shortTitle = R.string.widgets__weather__condition__poor__short_title, description = R.string.widgets__weather__condition__poor__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = FeeCondition.POOR.icon + currentFee = "$ 4.50", + currentFeeSats = 4500L, + currentFeeSatsFormatted = "4,500 ₿", + nextBlockFee = "45 ₿/vByte", + icon = FeeCondition.POOR.icon, ), ) } 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 97165243cb..d69ebb41b1 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 @@ -1,44 +1,35 @@ package to.bitkit.ui.screens.widgets.weather +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -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.text.AnnotatedString 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.ext.spaceToNewline +import to.bitkit.data.dto.FeeCondition +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.ui.components.BodyM -import to.bitkit.ui.components.Headline import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton -import to.bitkit.ui.components.Text13Up +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.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @@ -48,8 +39,8 @@ fun WeatherPreviewScreen( onClose: () -> Unit, onBack: () -> Unit, navigateEditWidget: () -> Unit, + modifier: Modifier = Modifier, ) { - val showWidgetTitles by weatherViewModel.showWidgetTitles.collectAsStateWithLifecycle() val customWeatherPreferences by weatherViewModel.customPreferences.collectAsStateWithLifecycle() val weather by weatherViewModel.currentWeather.collectAsStateWithLifecycle() val isWeatherWidgetEnabled by weatherViewModel.isWeatherWidgetEnabled.collectAsStateWithLifecycle() @@ -62,7 +53,6 @@ fun WeatherPreviewScreen( onBack = onBack, isWeatherWidgetEnabled = isWeatherWidgetEnabled, weatherPreferences = customWeatherPreferences, - showWidgetTitles = showWidgetTitles, weatherModel = weather, onClickEdit = navigateEditWidget, onClickDelete = { @@ -73,6 +63,7 @@ fun WeatherPreviewScreen( weatherViewModel.savePreferences() onClose() }, + modifier = modifier ) } @@ -82,122 +73,109 @@ fun WeatherPreviewContent( onClickEdit: () -> Unit, onClickDelete: () -> Unit, onClickSave: () -> Unit, - showWidgetTitles: Boolean, isWeatherWidgetEnabled: Boolean, weatherPreferences: WeatherPreferences, weatherModel: WeatherModel?, + modifier: Modifier = Modifier, ) { ScreenColumn( - modifier = Modifier.testTag("weather_preview_screen") + noBackground = true, + modifier = modifier + .background(Colors.Gray7) + .testTag("weather_preview_screen") ) { AppTopBar( - titleText = stringResource(R.string.widgets__widget__nav_title), + titleText = stringResource(R.string.widgets__weather__name), onBackClick = onBack, - actions = { DrawerNavIcon() }, ) Column( modifier = Modifier .padding(horizontal = 16.dp) .weight(1f) - .verticalScroll(rememberScrollState()) - .testTag("main_content") ) { - Spacer(modifier = Modifier.height(26.dp)) - - Row( - modifier = Modifier - .fillMaxWidth() - .testTag("header_row"), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween - ) { - Headline( - text = AnnotatedString(stringResource(R.string.widgets__weather__name).spaceToNewline()), - modifier = Modifier.testTag("widget_title"), - ) - Icon( - painter = painterResource(R.drawable.widget_cloud), - contentDescription = null, - tint = Color.Unspecified, - modifier = Modifier - .size(64.dp) - .testTag("widget_icon") - ) - } + VerticalSpacer(16.dp) BodyM( text = stringResource(R.string.widgets__weather__description), color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("widget_description") + modifier = Modifier.testTag("widget_description") ) + VerticalSpacer(16.dp) + HorizontalDivider( modifier = Modifier.testTag("divider") ) SettingsButtonRow( - title = stringResource(R.string.widgets__widget__edit), + title = stringResource(R.string.widgets__widget__settings), value = SettingsButtonValue.StringValue( if (weatherPreferences == WeatherPreferences()) { stringResource(R.string.widgets__widget__edit_default) } else { stringResource(R.string.widgets__widget__edit_custom) - } + }, ), onClick = onClickEdit, modifier = Modifier.testTag("WidgetEdit") ) - Spacer(modifier = Modifier.weight(1f)) - - Text13Up( - stringResource(R.string.common__preview), - color = Colors.White64, - modifier = Modifier - .padding(vertical = 16.dp) - .testTag("preview_label") - ) - weatherModel?.let { model -> - WeatherCard( + WidgetSizeCarousel( + smallContent = { + WeatherCardSmall( + weatherModel = model, + preferences = weatherPreferences, + modifier = Modifier.testTag("weather_card_small") + ) + }, + wideContent = { + WeatherCard( + weatherModel = model, + preferences = weatherPreferences, + modifier = Modifier + .fillMaxWidth() + .testTag("weather_card_wide") + ) + }, modifier = Modifier .fillMaxWidth() - .testTag("weather_card"), - showWidgetTitle = showWidgetTitles, - weatherModel = model, - preferences = weatherPreferences + .testTag("weather_preview_carousel") ) } } Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp, horizontal = 16.dp) + .padding( + start = 16.dp, + end = 16.dp, + bottom = 16.dp, + top = 22.dp, + ) .fillMaxWidth() - .testTag("buttons_row"), - horizontalArrangement = Arrangement.spacedBy(16.dp) + .testTag("buttons_row") ) { if (isWeatherWidgetEnabled) { SecondaryButton( text = stringResource(R.string.common__delete), + fullWidth = false, + onClick = onClickDelete, modifier = Modifier .weight(1f) - .testTag("WidgetDelete"), - fullWidth = false, - onClick = onClickDelete + .testTag("WidgetDelete") ) } PrimaryButton( - text = stringResource(R.string.common__save), + text = stringResource(R.string.widgets__widget__save), + fullWidth = false, + onClick = onClickSave, modifier = Modifier .weight(1f) - .testTag("WidgetSave"), - fullWidth = false, - onClick = onClickSave + .testTag("WidgetSave") ) } } @@ -209,19 +187,22 @@ private fun Preview() { AppThemeSurface { WeatherPreviewContent( onBack = {}, - showWidgetTitles = true, onClickEdit = {}, onClickDelete = {}, onClickSave = {}, weatherPreferences = WeatherPreferences(), weatherModel = WeatherModel( + condition = FeeCondition.GOOD, title = R.string.widgets__weather__condition__good__title, + shortTitle = R.string.widgets__weather__condition__good__short_title, description = R.string.widgets__weather__condition__good__description, - currentFee = "15 sat/vB", - nextBlockFee = "12 sat/vB", - icon = "☀️" + currentFee = "$ 0.52", + currentFeeSats = 520L, + currentFeeSatsFormatted = "520 ₿", + nextBlockFee = "6 ₿/vByte", + icon = "☀️", ), - isWeatherWidgetEnabled = false + isWeatherWidgetEnabled = false, ) } } @@ -232,24 +213,22 @@ private fun Preview2() { AppThemeSurface { WeatherPreviewContent( onBack = {}, - showWidgetTitles = false, onClickEdit = {}, onClickDelete = {}, onClickSave = {}, - weatherPreferences = WeatherPreferences( - showTitle = true, - showDescription = true, - showCurrentFee = true, - showNextBlockFee = true - ), + weatherPreferences = WeatherPreferences(selectedOption = WeatherDataOption.NEXT_BLOCK_INCLUSION), weatherModel = WeatherModel( + condition = FeeCondition.POOR, title = R.string.widgets__weather__condition__poor__title, + shortTitle = R.string.widgets__weather__condition__poor__short_title, description = R.string.widgets__weather__condition__poor__description, - currentFee = "45 sat/vB", - nextBlockFee = "50 sat/vB", - icon = "⛈️" + currentFee = "$ 4.50", + currentFeeSats = 4500L, + currentFeeSatsFormatted = "4,500 ₿", + nextBlockFee = "45 ₿/vByte", + icon = "⛈️", ), - isWeatherWidgetEnabled = true + isWeatherWidgetEnabled = true, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherStyles.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherStyles.kt new file mode 100644 index 0000000000..e5bd361a71 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherStyles.kt @@ -0,0 +1,32 @@ +package to.bitkit.ui.screens.widgets.weather + +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import to.bitkit.ui.theme.InterFontFamily + +internal val WeatherFeeValueTextStyle = TextStyle( + fontWeight = FontWeight.Bold, + fontSize = 30.sp, + lineHeight = 30.sp, + letterSpacing = (-1).sp, + fontFamily = InterFontFamily, +) + +@Composable +internal fun WeatherFeeValueText( + text: String, + color: Color, + modifier: Modifier = Modifier, +) { + Text( + text = text, + style = WeatherFeeValueTextStyle, + color = color, + modifier = modifier + ) +} 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 d90bd4a572..cfb0625ff5 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,6 +12,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.models.WidgetType +import to.bitkit.models.widget.WeatherDataOption import to.bitkit.models.widget.WeatherPreferences import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.screens.widgets.blocks.WeatherModel @@ -77,27 +78,10 @@ class WeatherViewModel @Inject constructor( // MARK: - Public Methods - fun toggleShowTitle() { + fun selectOption(option: WeatherDataOption) { _customPreferences.update { preferences -> - preferences.copy(showTitle = !preferences.showTitle) - } - } - - fun toggleShowDescription() { - _customPreferences.update { preferences -> - preferences.copy(showDescription = !preferences.showDescription) - } - } - - fun toggleShowCurrentFee() { - _customPreferences.update { preferences -> - preferences.copy(showCurrentFee = !preferences.showCurrentFee) - } - } - - fun toggleShowNextBlockFee() { - _customPreferences.update { preferences -> - preferences.copy(showNextBlockFee = !preferences.showNextBlockFee) + val next = if (preferences.selectedOption == option) null else option + preferences.copy(selectedOption = next) } } diff --git a/app/src/main/res/layout/appwidget_preview_weather.xml b/app/src/main/res/layout/appwidget_preview_weather.xml new file mode 100644 index 0000000000..8b624c13e0 --- /dev/null +++ b/app/src/main/res/layout/appwidget_preview_weather.xml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3a1213d84f..a2705c3447 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1165,17 +1165,21 @@ Discover everything Bitkit has to offer. Bitkit Suggestions The next block rate is close to the monthly averages. + Average Average Conditions All clear. Now would be a good time to transact on the blockchain. + Favorable Favorable Conditions If you are not in a hurry to transact, it may be better to wait a bit. + Poor Poor Conditions - Current average fee + Current fee Find out when it’s a good time to transact on the Bitcoin blockchain. Bitcoin Weather Next block inclusion CONTENT DATA + DISPLAY Widget Feed Custom Default diff --git a/app/src/main/res/xml/appwidget_info_weather.xml b/app/src/main/res/xml/appwidget_info_weather.xml new file mode 100644 index 0000000000..596e504f08 --- /dev/null +++ b/app/src/main/res/xml/appwidget_info_weather.xml @@ -0,0 +1,17 @@ + + diff --git a/changelog.d/next/927.added.md b/changelog.d/next/927.added.md new file mode 100644 index 0000000000..16156ca31e --- /dev/null +++ b/changelog.d/next/927.added.md @@ -0,0 +1 @@ +Bitcoin Weather home screen widget with v61 wide and compact layouts, including redesigned in-app weather card, preview, and edit screens