diff --git a/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt new file mode 100644 index 0000000000..ca1c0fc1a8 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/components/DrawerMenuWidgetsTest.kt @@ -0,0 +1,162 @@ +package to.bitkit.ui.components + +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.testTag +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 androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.Routes +import to.bitkit.ui.theme.AppThemeSurface + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@ComposeUi +class DrawerMenuWidgetsTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun testUnseenWidgetsIntroNavigatesToIntro() { + composeTestRule.setContent { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Open) + + DrawerMenuTestSurface { + NavHost( + navController = navController, + startDestination = Routes.Home, + ) { + composable { + Text("Home", modifier = Modifier.testTag("HomeRoute")) + } + composable { + Text("Widgets Intro", modifier = Modifier.testTag("WidgetsIntroRoute")) + } + } + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = false, + hasSeenShopIntro = true, + onBeforeNavigate = {}, + showWidgets = true, + ) + } + } + + composeTestRule.onNodeWithTag("DrawerWidgets").performClick() + + composeTestRule.onNodeWithTag("WidgetsIntroRoute").assertIsDisplayed() + } + + @Test + fun testSeenWidgetsIntroRequestsHomeWidgetsPage() { + composeTestRule.setContent { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Open) + val openWidgetsHome = remember { mutableStateOf(false) } + + DrawerMenuTestSurface { + NavHost( + navController = navController, + startDestination = Routes.Home, + ) { + composable { + Text("Home", modifier = Modifier.testTag("HomeRoute")) + } + } + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = true, + hasSeenShopIntro = true, + onBeforeNavigate = {}, + showWidgets = true, + onOpenWidgetsHome = { openWidgetsHome.value = true }, + ) + if (openWidgetsHome.value) { + Text("Widgets requested", modifier = Modifier.testTag("WidgetsHomeRequested")) + } + } + } + + composeTestRule.onNodeWithTag("DrawerWidgets").performClick() + + composeTestRule.onNodeWithTag("WidgetsHomeRequested").assertIsDisplayed() + } + + @Test + fun testSeenWidgetsIntroWithDisabledWidgetsOpensSheet() { + composeTestRule.setContent { + val navController = rememberNavController() + val drawerState = rememberDrawerState(DrawerValue.Open) + val openWidgetsSheet = remember { mutableStateOf(false) } + + DrawerMenuTestSurface { + NavHost( + navController = navController, + startDestination = Routes.Home, + ) { + composable { + Text("Home", modifier = Modifier.testTag("HomeRoute")) + } + } + DrawerMenu( + drawerState = drawerState, + rootNavController = navController, + hasSeenWidgetsIntro = true, + hasSeenShopIntro = true, + onBeforeNavigate = {}, + showWidgets = false, + onOpenWidgetsHome = { error("Should not request home widgets page") }, + onOpenWidgetsSheet = { openWidgetsSheet.value = true }, + ) + if (openWidgetsSheet.value) { + Text("Widgets sheet requested", modifier = Modifier.testTag("WidgetsSheetRequested")) + } + } + } + + composeTestRule.onNodeWithTag("DrawerWidgets").performClick() + + composeTestRule.onNodeWithTag("WidgetsSheetRequested").assertIsDisplayed() + } +} + +@Composable +private fun DrawerMenuTestSurface(content: @Composable () -> Unit) { + AppThemeSurface { + CompositionLocalProvider(LocalInspectionMode provides true) { + content() + } + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/AddWidgetsSheetContentTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/AddWidgetsSheetContentTest.kt new file mode 100644 index 0000000000..fe2b20b0e7 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/AddWidgetsSheetContentTest.kt @@ -0,0 +1,301 @@ +package to.bitkit.ui.screens.widgets + +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.getUnclippedBoundsInRoot +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithTag +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.onRoot +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollToNode +import androidx.compose.ui.test.performTouchInput +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import to.bitkit.models.WidgetType +import to.bitkit.models.widget.ArticleModel +import to.bitkit.models.widget.BlockModel +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@ComposeUi +class AddWidgetsSheetContentTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testGalleryShowsFigmaOrderedVisibleCards() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("widgets_gallery_screen").assertExists() + composeTestRule.onNodeWithTag("WidgetListItem-price").assertIsDisplayed() + composeTestRule.onNodeWithTag("WidgetListItem-weather").assertIsDisplayed() + + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-calculator")) + composeTestRule.onNodeWithTag("WidgetListItem-calculator").assertIsDisplayed() + } + + @Test + fun testGalleryTitleScrollsWithContent() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("widgets_gallery_title").assertIsDisplayed() + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-suggestions")) + composeTestRule.onNodeWithTag("widgets_gallery_title").assertIsNotDisplayed() + } + + @Test + fun testGalleryUsesWidgetPreviewComponents() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("price_card_small_chart").assertIsDisplayed() + composeTestRule.onNodeWithTag("weather_card_small").assertIsDisplayed() + composeTestRule.onNodeWithTag("headline_card_wide").assertIsDisplayed() + composeTestRule.onNodeWithTag("block_label").assertIsDisplayed() + + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-suggestions")) + composeTestRule.onNodeWithTag("Suggestion-back_up").assertIsDisplayed() + } + + @Test + fun testGalleryUsesProvidedWidgetData() { + val article = ArticleModel( + timeAgo = "5 min ago", + title = "Live preview headline", + publisher = "live.source", + link = "https://live.source", + ) + val block = BlockModel( + height = "999,999", + time = "09:08:07 UTC", + date = "5/29/2026", + transactionCount = "9,876", + size = "1,234kb", + fees = "50 000", + ) + + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + article = article, + block = block, + fact = "Live preview fact", + ) + } + } + + composeTestRule.onNodeWithText("Live preview headline").assertIsDisplayed() + composeTestRule.onNodeWithText("live.source").assertIsDisplayed() + + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-blocks")) + composeTestRule.onNodeWithText("999,999").assertIsDisplayed() + + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-facts")) + composeTestRule.onNodeWithText("Live preview fact").assertIsDisplayed() + } + + @Test + fun testTwoColumnGalleryItemsHaveEqualHeight() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + val priceBounds = composeTestRule.onNodeWithTag("WidgetListItem-price").getUnclippedBoundsInRoot() + val weatherBounds = composeTestRule.onNodeWithTag("WidgetListItem-weather").getUnclippedBoundsInRoot() + + assertEquals(priceBounds.bottom - priceBounds.top, weatherBounds.bottom - weatherBounds.top) + + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-calculator")) + + val factsBounds = composeTestRule.onNodeWithTag("WidgetListItem-facts-layout").getUnclippedBoundsInRoot() + val calculatorBounds = composeTestRule.onNodeWithTag("WidgetListItem-calculator-layout").getUnclippedBoundsInRoot() + + assertEquals(factsBounds.bottom - factsBounds.top, calculatorBounds.bottom - calculatorBounds.top) + } + + @Test + fun testGalleryTapSelectsWidget() { + var selectedWidget: WidgetType? = null + + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = { selectedWidget = it }, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("WidgetListItem-weather").performClick() + + assert(selectedWidget == WidgetType.WEATHER) + + selectedWidget = null + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-facts")) + composeTestRule.onNodeWithTag("WidgetListItem-facts").performClick() + + assert(selectedWidget == WidgetType.FACTS) + } + + @Test + fun testPriceChartTapSelectsWidget() { + var selectedWidget: WidgetType? = null + + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = { selectedWidget = it }, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("price_card_small_chart").performTouchInput { + down(center) + up() + } + + assert(selectedWidget == WidgetType.PRICE) + } + + @Test + fun testHeadlineContentTapSelectsWidget() { + var selectedWidget: WidgetType? = null + + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = { selectedWidget = it }, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onAllNodesWithTag("headline_text")[0].performTouchInput { + down(center) + up() + } + + assert(selectedWidget == WidgetType.NEWS) + } + + @Test + fun testDisabledGalleryShowsSettingsButton() { + var enableSettingsClicked = false + + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = false, + onWidgetSelected = {}, + onEnableInSettingsClick = { enableSettingsClicked = true }, + ) + } + } + + composeTestRule.onNodeWithTag("WidgetListItem-price").assertIsDisplayed() + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-calculator")) + composeTestRule.onNodeWithTag("WidgetListItem-calculator").assertIsDisplayed() + composeTestRule.onNodeWithTag("widgets_gallery_scroll") + .performScrollToNode(hasTestTag("WidgetListItem-suggestions")) + composeTestRule.onNodeWithTag("WidgetListItem-suggestions").assertIsDisplayed() + + composeTestRule.onNodeWithTag("WidgetEnableInSettings").assertExists().performClick() + assert(enableSettingsClicked) + } + + @Test + fun testDisabledGalleryHeadlineCardIsNotClickable() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = false, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("headline_card_wide").assertHasNoClickAction() + } + + @Test + fun testSettingsButtonHasBottomPadding() { + composeTestRule.setContent { + AppThemeSurface { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = false, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + + val rootBottom = composeTestRule.onRoot().getUnclippedBoundsInRoot().bottom + val buttonBottom = composeTestRule.onNodeWithTag("WidgetEnableInSettings") + .getUnclippedBoundsInRoot() + .bottom + + assert(buttonBottom < rootBottom) + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreenTest.kt new file mode 100644 index 0000000000..868bf964a4 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreenTest.kt @@ -0,0 +1,64 @@ +package to.bitkit.ui.screens.widgets + +import androidx.compose.ui.test.getUnclippedBoundsInRoot +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 kotlin.test.assertTrue +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@ComposeUi +class WidgetsIntroScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testWidgetsIntroActions() { + var viewOrganizeClicked = false + var addWidgetClicked = false + + composeTestRule.setContent { + AppThemeSurface { + WidgetsIntroScreen( + onViewOrganize = { viewOrganizeClicked = true }, + onAddWidget = { addWidgetClicked = true }, + onBackClick = {}, + ) + } + } + + composeTestRule.onNodeWithTag("WidgetsOnboarding-view-organize").assertExists().performClick() + assert(viewOrganizeClicked) + + composeTestRule.onNodeWithTag("WidgetsOnboarding-button").assertExists().performClick() + assert(addWidgetClicked) + } + + @Test + fun testWidgetsIntroActionsAreHorizontal() { + composeTestRule.setContent { + AppThemeSurface { + WidgetsIntroScreen( + onViewOrganize = {}, + onAddWidget = {}, + onBackClick = {}, + ) + } + } + + val viewOrganizeBounds = composeTestRule + .onNodeWithTag("WidgetsOnboarding-view-organize") + .getUnclippedBoundsInRoot() + val addWidgetBounds = composeTestRule + .onNodeWithTag("WidgetsOnboarding-button") + .getUnclippedBoundsInRoot() + + assertTrue(viewOrganizeBounds.right < addWidgetBounds.left) + assertTrue(viewOrganizeBounds.top < addWidgetBounds.bottom) + assertTrue(addWidgetBounds.top < viewOrganizeBounds.bottom) + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt index 44935c6b24..3223e10e94 100644 --- a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/calculator/CalculatorCardIntegrationTest.kt @@ -51,7 +51,6 @@ import to.bitkit.test.annotations.DeviceIntegration import to.bitkit.test.annotations.DeviceUiIntegration import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCard import to.bitkit.ui.theme.AppThemeSurface -import to.bitkit.viewmodels.CurrencyViewModel import java.util.Locale import javax.inject.Inject import javax.inject.Named @@ -88,7 +87,6 @@ class CalculatorCardIntegrationTest { private lateinit var viewModelStore: ViewModelStore private lateinit var calculatorViewModel: CalculatorViewModel - private lateinit var currencyViewModel: CurrencyViewModel private lateinit var previousWidgetsData: WidgetsData private lateinit var previousSettingsData: SettingsData private lateinit var previousCacheData: AppCacheData @@ -110,7 +108,6 @@ class CalculatorCardIntegrationTest { it.copy( selectedCurrency = USD, displayUnit = BitcoinDisplayUnit.MODERN, - showWidgetTitles = true, ) } cacheStore.update { it.copy(cachedRates = listOf(testUsdRate)) } @@ -136,7 +133,6 @@ class CalculatorCardIntegrationTest { } calculatorViewModel = createCalculatorViewModel() - currencyViewModel = createCurrencyViewModel() } @After @@ -203,31 +199,18 @@ class CalculatorCardIntegrationTest { override fun create(modelClass: Class): T { return CalculatorViewModel( widgetsRepo = widgetsRepo, + currencyRepo = currencyRepo, ) as T } }, )[CalculatorViewModel::class.java] } - private fun createCurrencyViewModel(): CurrencyViewModel = ViewModelProvider( - viewModelStore, - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T { - return CurrencyViewModel( - currencyRepo = currencyRepo, - ) as T - } - }, - )[CurrencyViewModel::class.java] - private fun setCalculatorCard() { composeTestRule.setContent { AppThemeSurface { CalculatorCard( - currencyViewModel = currencyViewModel, calculatorViewModel = calculatorViewModel, - showWidgetTitle = true, modifier = Modifier.fillMaxWidth() ) } @@ -254,14 +237,14 @@ class CalculatorCardIntegrationTest { ) { runCatching { composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { - calculatorViewModel.calculatorValues.value.btcValue == btcValue && - calculatorViewModel.calculatorValues.value.fiatValue == fiatValue + calculatorViewModel.uiState.value.btcValue == btcValue && + calculatorViewModel.uiState.value.fiatValue == fiatValue } }.onFailure { throw AssertionError( buildString { append("Expected calculatorValues btcValue='$btcValue', fiatValue='$fiatValue', ") - append("but was '${calculatorViewModel.calculatorValues.value}'. Persisted values were ") + append("but was '${calculatorViewModel.uiState.value}'. Persisted values were ") append("'${widgetsRepo.widgetsDataFlow.value.calculatorValues}'. Semantics tree:\n") append(composeTestRule.onRoot(useUnmergedTree = true).printToString()) }, @@ -272,6 +255,8 @@ class CalculatorCardIntegrationTest { val expectedValues = CalculatorValues( btcValue = btcValue, fiatValue = fiatValue, + satsValue = btcValue.toLong(), + displayUnit = BitcoinDisplayUnit.MODERN, ) runCatching { composeTestRule.waitUntil(timeoutMillis = TIMEOUT_MS) { @@ -303,6 +288,8 @@ class CalculatorCardIntegrationTest { CalculatorValues( btcValue = btcValue, fiatValue = fiatValue, + satsValue = btcValue.toLong(), + displayUnit = BitcoinDisplayUnit.MODERN, ), widgetsRepo.widgetsDataFlow.value.calculatorValues, ) diff --git a/app/src/androidTest/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreenTest.kt b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreenTest.kt new file mode 100644 index 0000000000..181732e2ed --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreenTest.kt @@ -0,0 +1,34 @@ +package to.bitkit.ui.screens.widgets.suggestions + +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import org.junit.Rule +import org.junit.Test +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.theme.AppThemeSurface + +@ComposeUi +class SuggestionsPreviewScreenTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun testDefaultPreviewShowsFourSuggestions() { + composeTestRule.setContent { + AppThemeSurface { + SuggestionsPreviewGrid( + onSuggestionClick = {}, + modifier = Modifier + ) + } + } + + composeTestRule.onNodeWithTag("Suggestion-back_up").assertIsDisplayed() + composeTestRule.onNodeWithTag("Suggestion-secure").assertIsDisplayed() + composeTestRule.onNodeWithTag("Suggestion-lightning").assertIsDisplayed() + composeTestRule.onNodeWithTag("Suggestion-support").assertIsDisplayed() + } +} diff --git a/app/src/androidTest/java/to/bitkit/ui/shared/modifiers/SheetHeightTest.kt b/app/src/androidTest/java/to/bitkit/ui/shared/modifiers/SheetHeightTest.kt new file mode 100644 index 0000000000..f31a4f28d4 --- /dev/null +++ b/app/src/androidTest/java/to/bitkit/ui/shared/modifiers/SheetHeightTest.kt @@ -0,0 +1,78 @@ +package to.bitkit.ui.shared.modifiers + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.unit.dp +import androidx.test.ext.junit.runners.AndroidJUnit4 +import dagger.hilt.android.testing.HiltAndroidRule +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import to.bitkit.test.annotations.ComposeUi +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Insets +import to.bitkit.ui.theme.TopBarHeight + +@HiltAndroidTest +@RunWith(AndroidJUnit4::class) +@ComposeUi +class SheetHeightTest { + + @get:Rule + val hiltRule = HiltAndroidRule(this) + + @get:Rule + val composeTestRule = createComposeRule() + + @Before + fun setup() { + hiltRule.inject() + } + + @Test + fun largeNonModalHeightIgnoresBottomInset() { + var expectedHeight = 0 + var actualHeight = 0 + + composeTestRule.setContent { + AppThemeSurface { + val density = LocalDensity.current + val windowHeight = LocalWindowInfo.current.containerSize.height + val topInset = Insets.Top + val expected = with(density) { + (windowHeight.toDp() - topInset - TopBarHeight + 6.dp).roundToPx() + } + + SideEffect { + expectedHeight = expected + } + + Box(modifier = Modifier.fillMaxSize()) { + Box( + modifier = Modifier + .sheetHeight(size = SheetSize.LARGE) + .fillMaxWidth() + .onSizeChanged { actualHeight = it.height } + ) + } + } + } + + composeTestRule.waitUntil { + expectedHeight > 0 && actualHeight > 0 + } + + assertEquals(expectedHeight, actualHeight) + } +} diff --git a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt index b7ffce4527..096d0c141d 100644 --- a/app/src/main/java/to/bitkit/repositories/BackupRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/BackupRepo.kt @@ -101,6 +101,7 @@ class BackupRepo @Inject constructor( private var periodicCheckJob: Job? = null private val runningBackups = ConcurrentHashMap.newKeySet() // Tracks active jobs since app start + private val failedBackupRequired = ConcurrentHashMap() private var isObserving = false private var lastNotificationTime = 0L @@ -119,7 +120,11 @@ class BackupRepo @Inject constructor( fun setWiping(isWiping: Boolean) = _isWiping.update { isWiping } private fun currentTimeMillis(): Long = nowMillis(clock) private fun shouldSkipBackup(): Boolean = _isRestoring.value || _isWiping.value - private fun BackupItemStatus.shouldBackup() = this.isRequired && !this.running && !shouldSkipBackup() + private fun BackupItemStatus.shouldBackup(category: BackupCategory) = + this.isRequired && + !this.running && + !shouldSkipBackup() && + failedBackupRequired[category] != this.required fun startObservingBackups() { if (isObserving) return @@ -197,11 +202,12 @@ class BackupRepo @Inject constructor( cacheStore.backupStatuses .map { statuses -> statuses[category] ?: BackupItemStatus() } .distinctUntilChanged { old, new -> - // restart scheduling when synced or required timestamps change - old.synced == new.synced && old.required == new.required + old.synced == new.synced && + old.required == new.required && + old.running == new.running } .collect { status -> - if (status.shouldBackup()) { + if (status.shouldBackup(category)) { scheduleBackup(category) } } @@ -355,6 +361,7 @@ class BackupRepo @Inject constructor( private fun markBackupRequired(category: BackupCategory) { scope.launch { + failedBackupRequired -= category cacheStore.updateBackupStatus(category) { it.copy(required = currentTimeMillis()) } @@ -441,6 +448,7 @@ class BackupRepo @Inject constructor( Logger.debug("Backup starting for: '$category'", context = TAG) runningBackups += category + failedBackupRequired -= category cacheStore.updateBackupStatus(category) { it.copy(running = true, required = currentTimeMillis()) } @@ -448,6 +456,7 @@ class BackupRepo @Inject constructor( vssBackupClient.putObject(key = category.name, data = getBackupDataBytes(category)) .onSuccess { runningBackups -= category + failedBackupRequired -= category cacheStore.updateBackupStatus(category) { it.copy( running = false, @@ -459,6 +468,7 @@ class BackupRepo @Inject constructor( .onFailure { e -> runningBackups -= category cacheStore.updateBackupStatus(category) { + failedBackupRequired[category] = it.required it.copy(running = false) } Logger.error("Backup failed for: '$category'", e, context = TAG) diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index 9880dd2f29..060b8311a0 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -47,12 +48,12 @@ import kotlinx.serialization.Serializable import to.bitkit.env.Env import to.bitkit.models.NodeLifecycleState import to.bitkit.models.Toast -import to.bitkit.models.WidgetType import to.bitkit.repositories.ConnectivityState import to.bitkit.ui.Routes.ExternalConnection import to.bitkit.ui.components.AuthCheckScreen import to.bitkit.ui.components.DrawerMenu import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetHandlePlacement import to.bitkit.ui.components.SheetHost import to.bitkit.ui.components.TabBar import to.bitkit.ui.components.TimedSheetType @@ -133,25 +134,7 @@ import to.bitkit.ui.screens.wallets.activity.TagSelectorSheet import to.bitkit.ui.screens.wallets.receive.ReceiveRoute import to.bitkit.ui.screens.wallets.receive.ReceiveSheet import to.bitkit.ui.screens.wallets.suggestion.BuyIntroScreen -import to.bitkit.ui.screens.widgets.AddWidgetsScreen import to.bitkit.ui.screens.widgets.WidgetsIntroScreen -import to.bitkit.ui.screens.widgets.blocks.BlocksEditScreen -import to.bitkit.ui.screens.widgets.blocks.BlocksPreviewScreen -import to.bitkit.ui.screens.widgets.blocks.BlocksViewModel -import to.bitkit.ui.screens.widgets.calculator.CalculatorPreviewScreen -import to.bitkit.ui.screens.widgets.facts.FactsPreviewScreen -import to.bitkit.ui.screens.widgets.facts.FactsViewModel -import to.bitkit.ui.screens.widgets.headlines.HeadlinesEditScreen -import to.bitkit.ui.screens.widgets.headlines.HeadlinesPreviewScreen -import to.bitkit.ui.screens.widgets.headlines.HeadlinesViewModel -import to.bitkit.ui.screens.widgets.price.PriceEditScreen -import to.bitkit.ui.screens.widgets.price.PricePreviewScreen -import to.bitkit.ui.screens.widgets.price.PriceViewModel -import to.bitkit.ui.screens.widgets.suggestions.SuggestionsPreviewScreen -import to.bitkit.ui.screens.widgets.suggestions.SuggestionsViewModel -import to.bitkit.ui.screens.widgets.weather.WeatherEditScreen -import to.bitkit.ui.screens.widgets.weather.WeatherPreviewScreen -import to.bitkit.ui.screens.widgets.weather.WeatherViewModel import to.bitkit.ui.settings.BackupSettingsScreen import to.bitkit.ui.settings.BlocktankRegtestScreen import to.bitkit.ui.settings.CJitDetailScreen @@ -204,6 +187,7 @@ import to.bitkit.ui.sheets.QuickPayIntroSheet import to.bitkit.ui.sheets.SendRoute import to.bitkit.ui.sheets.SendSheet import to.bitkit.ui.sheets.UpdateSheet +import to.bitkit.ui.sheets.WidgetsSheet import to.bitkit.ui.utils.AutoReadClipboardHandler import to.bitkit.ui.utils.RequestNotificationPermissions import to.bitkit.ui.utils.composableWithDefaultTransitions @@ -399,7 +383,16 @@ fun ContentView( val isProfileAuthenticated by settingsViewModel.isPubkyAuthenticated.collectAsStateWithLifecycle() val hasPubkyContacts by settingsViewModel.hasPubkyContacts.collectAsStateWithLifecycle() val isPaykitEnabled by settingsViewModel.isPaykitEnabled.collectAsStateWithLifecycle() + val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() val currentSheet by appViewModel.currentSheet.collectAsStateWithLifecycle() + var homeWidgetsPageRequest by remember { mutableIntStateOf(0) } + val navigateToHomeWidgets = { + homeWidgetsPageRequest++ + navController.navigateToHome() + } + val onConsumeHomeWidgetsPageRequest = { + homeWidgetsPageRequest = 0 + } Box( modifier = modifier.fillMaxSize() @@ -407,6 +400,10 @@ fun ContentView( SheetHost( shouldExpand = currentSheet != null, onDismiss = { appViewModel.hideSheet() }, + sheetHandlePlacement = when (currentSheet) { + is Sheet.Widgets -> SheetHandlePlacement.ContentOverlay + else -> SheetHandlePlacement.ScaffoldSlot + }, sheets = { when (val sheet = currentSheet) { null -> Unit @@ -438,6 +435,18 @@ fun ContentView( Sheet.ChangePin -> ChangePinSheet(appViewModel) Sheet.DisablePin -> DisablePinSheet(appViewModel) is Sheet.Backup -> BackupSheet(sheet, onDismiss = { appViewModel.hideSheet() }) + is Sheet.Widgets -> { + WidgetsSheet( + sheet = sheet, + app = appViewModel, + fiatSymbol = LocalCurrencies.current.currencySymbol, + showWidgets = showWidgets, + onNavigateHomeWidgets = navigateToHomeWidgets, + onOpenWidgetsSettings = { + navController.navigateTo(Routes.WidgetsSettings) + }, + ) + } is Sheet.LnurlAuth -> LnurlAuthSheet(sheet, appViewModel) Sheet.ForceTransfer -> ForceTransferSheet(appViewModel, transferViewModel) Sheet.ConnectionClosed -> ConnectionClosedSheet( @@ -516,6 +525,9 @@ fun ContentView( settingsViewModel = settingsViewModel, currencyViewModel = currencyViewModel, transferViewModel = transferViewModel, + homeWidgetsPageRequest = homeWidgetsPageRequest, + onConsumeHomeWidgetsPageRequest = onConsumeHomeWidgetsPageRequest, + onNavigateHomeWidgets = navigateToHomeWidgets, onHomeCalculatorInputActiveChanged = { isHomeCalculatorInputActive = it }, ) @@ -562,6 +574,9 @@ fun ContentView( hasContacts = hasPubkyContacts, isProfileAuthenticated = isProfileAuthenticated, isPaykitEnabled = isPaykitEnabled, + showWidgets = showWidgets, + onOpenWidgetsHome = navigateToHomeWidgets, + onOpenWidgetsSheet = { appViewModel.showSheet(Sheet.Widgets()) }, modifier = Modifier.align(Alignment.TopEnd) ) } @@ -578,6 +593,9 @@ private fun RootNavHost( settingsViewModel: SettingsViewModel, currencyViewModel: CurrencyViewModel, transferViewModel: TransferViewModel, + homeWidgetsPageRequest: Int, + onConsumeHomeWidgetsPageRequest: () -> Unit, + onNavigateHomeWidgets: () -> Unit, onHomeCalculatorInputActiveChanged: (Boolean) -> Unit, ) { val scope = rememberCoroutineScope() @@ -590,6 +608,8 @@ private fun RootNavHost( settingsViewModel = settingsViewModel, navController = navController, drawerState = drawerState, + homeWidgetsPageRequest = homeWidgetsPageRequest, + onConsumeHomeWidgetsPageRequest = onConsumeHomeWidgetsPageRequest, onCalculatorInputActiveChanged = onHomeCalculatorInputActiveChanged, ) allActivity( @@ -617,7 +637,12 @@ private fun RootNavHost( logs(navController) suggestions(navController) support(navController) - widgets(navController, settingsViewModel) + widgets( + navController = navController, + settingsViewModel = settingsViewModel, + appViewModel = appViewModel, + onNavigateHomeWidgets = onNavigateHomeWidgets, + ) update() recoveryMode(navController, appViewModel) @@ -834,6 +859,8 @@ private fun NavGraphBuilder.home( settingsViewModel: SettingsViewModel, navController: NavHostController, drawerState: DrawerState, + homeWidgetsPageRequest: Int, + onConsumeHomeWidgetsPageRequest: () -> Unit, onCalculatorInputActiveChanged: (Boolean) -> Unit, ) { composable { @@ -861,6 +888,8 @@ private fun NavGraphBuilder.home( walletViewModel = walletViewModel, appViewModel = appViewModel, activityListViewModel = activityListViewModel, + widgetsPageRequest = homeWidgetsPageRequest, + onConsumeWidgetsPageRequest = onConsumeHomeWidgetsPageRequest, onCalculatorInputActiveChanged = onCalculatorInputActiveChanged, ) } @@ -1190,6 +1219,9 @@ private fun NavGraphBuilder.profile( onNavigateToPayContacts = { navController.navigateTo(Routes.PayContacts) { popUpTo(Routes.Home) } }, + onNavigateToProfile = { + navController.navigateTo(Routes.Profile) { popUpTo(Routes.Home) } + }, onBackClick = { navController.popBackStack() }, ) } @@ -1582,169 +1614,31 @@ private fun NavGraphBuilder.support( } } -@Suppress("LongMethod") private fun NavGraphBuilder.widgets( navController: NavHostController, settingsViewModel: SettingsViewModel, + appViewModel: AppViewModel, + onNavigateHomeWidgets: () -> Unit, ) { composableWithDefaultTransitions { + val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() + WidgetsIntroScreen( - onContinue = { + onViewOrganize = { settingsViewModel.setHasSeenWidgetsIntro(true) - navController.navigateTo(Routes.AddWidget) - }, - onBackClick = { navController.popBackStack() }, - ) - } - composableWithDefaultTransitions { - val showWidgets by settingsViewModel.showWidgets.collectAsStateWithLifecycle() - AddWidgetsScreen( - onWidgetSelected = { widgetType -> - when (widgetType) { - WidgetType.BLOCK -> navController.navigateTo(Routes.BlocksPreview) - WidgetType.CALCULATOR -> navController.navigateTo(Routes.CalculatorPreview) - WidgetType.FACTS -> navController.navigateTo(Routes.FactsPreview) - WidgetType.NEWS -> navController.navigateTo(Routes.HeadlinesPreview) - WidgetType.PRICE -> navController.navigateTo(Routes.PricePreview) - WidgetType.WEATHER -> navController.navigateTo(Routes.WeatherPreview) - WidgetType.SUGGESTIONS -> navController.navigateTo(Routes.SuggestionsPreview) + if (showWidgets) { + onNavigateHomeWidgets() + } else { + appViewModel.showSheet(Sheet.Widgets()) } }, - fiatSymbol = LocalCurrencies.current.currencySymbol, + onAddWidget = { + settingsViewModel.setHasSeenWidgetsIntro(true) + appViewModel.showSheet(Sheet.Widgets()) + }, onBackClick = { navController.popBackStack() }, - showWidgets = showWidgets, - onEnableInSettingsClick = { navController.navigateTo(Routes.WidgetsSettings) }, - ) - } - composableWithDefaultTransitions { - val viewModel = hiltViewModel() - SuggestionsPreviewScreen( - suggestionsViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - ) - } - composableWithDefaultTransitions { - CalculatorPreviewScreen( - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, ) } - navigationWithDefaultTransitions( - startDestination = Routes.HeadlinesPreview - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Headlines) } - val viewModel = hiltViewModel(parentEntry) - - HeadlinesPreviewScreen( - headlinesViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - navigateEditWidget = { navController.navigateTo(Routes.HeadlinesEdit) }, - ) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Headlines) } - val viewModel = hiltViewModel(parentEntry) - - HeadlinesEditScreen( - headlinesViewModel = viewModel, - onBack = { navController.popBackStack() }, - navigatePreview = { - navController.navigateTo(Routes.HeadlinesPreview) - } - ) - } - } - navigationWithDefaultTransitions( - startDestination = Routes.FactsPreview - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Facts) } - val viewModel = hiltViewModel(parentEntry) - - FactsPreviewScreen( - factsViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - ) - } - } - navigationWithDefaultTransitions( - startDestination = Routes.BlocksPreview - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Blocks) } - val viewModel = hiltViewModel(parentEntry) - - BlocksPreviewScreen( - blocksViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - navigateEditWidget = { navController.navigateTo(Routes.BlocksEdit) }, - ) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Blocks) } - val viewModel = hiltViewModel(parentEntry) - - BlocksEditScreen( - blocksViewModel = viewModel, - onBack = { navController.popBackStack() }, - navigatePreview = { navController.navigateTo(Routes.BlocksPreview) } - ) - } - } - navigationWithDefaultTransitions( - startDestination = Routes.WeatherPreview - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Weather) } - val viewModel = hiltViewModel(parentEntry) - - WeatherPreviewScreen( - weatherViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - navigateEditWidget = { navController.navigateTo(Routes.WeatherEdit) }, - ) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Weather) } - val viewModel = hiltViewModel(parentEntry) - - WeatherEditScreen( - weatherViewModel = viewModel, - onBack = { navController.popBackStack() }, - navigatePreview = { navController.navigateTo(Routes.WeatherPreview) } - ) - } - } - navigationWithDefaultTransitions( - startDestination = Routes.PricePreview - ) { - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Price) } - val viewModel = hiltViewModel(parentEntry) - - PricePreviewScreen( - priceViewModel = viewModel, - onClose = { navController.navigateToHome() }, - onBack = { navController.popBackStack() }, - navigateEditWidget = { navController.navigateTo(Routes.PriceEdit) }, - ) - } - composableWithDefaultTransitions { - val parentEntry = remember(it) { navController.getBackStackEntry(Routes.Price) } - val viewModel = hiltViewModel(parentEntry) - PriceEditScreen( - viewModel = viewModel, - onBack = { navController.popBackStack() }, - navigatePreview = { navController.navigateTo(Routes.PricePreview) } - ) - } - } } // endregion @@ -2120,57 +2014,6 @@ sealed interface Routes { @Serializable data object WidgetsIntro : Routes - @Serializable - data object AddWidget : Routes - - @Serializable - data object SuggestionsPreview : Routes - - @Serializable - data object Headlines : Routes - - @Serializable - data object HeadlinesPreview : Routes - - @Serializable - data object HeadlinesEdit : Routes - - @Serializable - data object Facts : Routes - - @Serializable - data object FactsPreview : Routes - - @Serializable - data object Blocks : Routes - - @Serializable - data object BlocksPreview : Routes - - @Serializable - data object BlocksEdit : Routes - - @Serializable - data object Weather : Routes - - @Serializable - data object WeatherPreview : Routes - - @Serializable - data object WeatherEdit : Routes - - @Serializable - data object Price : Routes - - @Serializable - data object PricePreview : Routes - - @Serializable - data object PriceEdit : Routes - - @Serializable - data object CalculatorPreview : Routes - @Serializable data object AppStatus : Routes diff --git a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt index a050a84ac1..74174edec0 100644 --- a/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt +++ b/app/src/main/java/to/bitkit/ui/components/DrawerMenu.kt @@ -70,7 +70,10 @@ fun DrawerMenu( hasSeenWidgetsIntro: Boolean, hasSeenShopIntro: Boolean, onBeforeNavigate: (Routes?) -> Unit, + showWidgets: Boolean, modifier: Modifier = Modifier, + onOpenWidgetsHome: () -> Unit = {}, + onOpenWidgetsSheet: () -> Unit = {}, hasSeenProfileIntro: Boolean = false, hasSeenContactsIntro: Boolean = false, hasContacts: Boolean = false, @@ -114,8 +117,13 @@ fun DrawerMenu( onBeforeNavigate(Routes.WidgetsIntro) rootNavController.navigateIfNotCurrent(Routes.WidgetsIntro) } else { - onBeforeNavigate(Routes.AddWidget) - rootNavController.navigateIfNotCurrent(Routes.AddWidget) + if (showWidgets) { + onBeforeNavigate(Routes.Home) + onOpenWidgetsHome() + } else { + onBeforeNavigate(null) + onOpenWidgetsSheet() + } } }, onClickShop = { @@ -383,6 +391,7 @@ private fun Preview() { hasSeenWidgetsIntro = false, hasSeenShopIntro = false, onBeforeNavigate = {}, + showWidgets = true, modifier = Modifier.align(Alignment.TopEnd) ) } diff --git a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt index e0001d5f90..40d30671ff 100644 --- a/app/src/main/java/to/bitkit/ui/components/SheetHost.kt +++ b/app/src/main/java/to/bitkit/ui/components/SheetHost.kt @@ -5,8 +5,10 @@ import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.material3.BottomSheetScaffold import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme @@ -18,10 +20,15 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import kotlinx.coroutines.launch import to.bitkit.models.SamRockSetupRequest import to.bitkit.ui.screens.wallets.receive.ReceiveRoute @@ -29,12 +36,18 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute import to.bitkit.ui.sheets.SendRoute +import to.bitkit.ui.sheets.WidgetsRoute import to.bitkit.ui.theme.AppShapes import to.bitkit.ui.theme.Colors enum class SheetSize { LARGE, MEDIUM, SMALL, CALENDAR; } -private val sheetContainerColor = Color(0xFF141414) // Equivalent to White08 on a Black background +private val DefaultSheetContainerColor = Color(0xFF141414) // Equivalent to White08 on a Black background + +enum class SheetHandlePlacement { + ScaffoldSlot, + ContentOverlay, +} @Stable sealed interface Sheet { @@ -44,6 +57,7 @@ sealed interface Sheet { data object ChangePin : Sheet data object DisablePin : Sheet data class Backup(val route: BackupRoute = BackupRoute.ShowMnemonic) : Sheet + data class Widgets(val route: WidgetsRoute = WidgetsRoute.Gallery) : Sheet data object ActivityDateRangeSelector : Sheet data object ActivityTagSelector : Sheet data class LnurlAuth(val domain: String, val lnurl: String, val k1: String) : Sheet @@ -75,6 +89,8 @@ enum class TimedSheetType(val priority: Int) { fun SheetHost( shouldExpand: Boolean, onDismiss: () -> Unit = {}, + sheetHandlePlacement: SheetHandlePlacement = SheetHandlePlacement.ScaffoldSlot, + sheetContainerColor: Color = DefaultSheetContainerColor, sheets: @Composable ColumnScope.() -> Unit, content: @Composable () -> Unit, ) { @@ -82,6 +98,11 @@ fun SheetHost( val scaffoldState = rememberBottomSheetScaffoldState( bottomSheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) ) + var wasSheetVisible by remember { mutableStateOf(false) } + val resolvedSheetContainerColor = when (sheetHandlePlacement) { + SheetHandlePlacement.ScaffoldSlot -> sheetContainerColor + SheetHandlePlacement.ContentOverlay -> Color.Transparent + } // Automatically expand or hide the bottom sheet based on bool flag LaunchedEffect(shouldExpand) { @@ -92,10 +113,11 @@ fun SheetHost( } } - // Observe the state of the bottom sheet to invoke onDismiss callback - // TODO prevent onDismiss call during first render LaunchedEffect(scaffoldState.bottomSheetState.isVisible) { - if (!scaffoldState.bottomSheetState.isVisible) { + if (scaffoldState.bottomSheetState.isVisible) { + wasSheetVisible = true + } else if (wasSheetVisible) { + wasSheetVisible = false onDismiss() } } @@ -105,9 +127,19 @@ fun SheetHost( scaffoldState = scaffoldState, sheetPeekHeight = 0.dp, sheetShape = AppShapes.sheet, - sheetContent = sheets, - sheetDragHandle = { SheetDragHandle() }, - sheetContainerColor = sheetContainerColor, + sheetContent = { + when (sheetHandlePlacement) { + SheetHandlePlacement.ScaffoldSlot -> sheets() + SheetHandlePlacement.ContentOverlay -> OverlayHandleSheetContent(sheets) + } + }, + sheetDragHandle = when (sheetHandlePlacement) { + SheetHandlePlacement.ScaffoldSlot -> { + { SheetDragHandle() } + } + SheetHandlePlacement.ContentOverlay -> null + }, + sheetContainerColor = resolvedSheetContainerColor, sheetContentColor = MaterialTheme.colorScheme.onSurface, ) { content() @@ -130,6 +162,23 @@ fun SheetHost( } } +@Composable +private fun OverlayHandleSheetContent( + sheets: @Composable ColumnScope.() -> Unit, +) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + sheets() + } + + SheetDragHandle( + modifier = Modifier + .align(Alignment.TopCenter) + .zIndex(1f) + ) + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun Scrim( diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt index fcbc55a56b..7d464f716c 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceScreen.kt @@ -65,6 +65,7 @@ fun PubkyChoiceScreen( onNavigateToCreateProfile: () -> Unit, onNavigateToContactImportOverview: () -> Unit, onNavigateToPayContacts: () -> Unit, + onNavigateToProfile: () -> Unit, onBackClick: () -> Unit, ) { val context = LocalContext.current @@ -81,6 +82,7 @@ fun PubkyChoiceScreen( PubkyChoiceEffect.NavigateToCreateProfile -> onNavigateToCreateProfile() PubkyChoiceEffect.NavigateToContactImportOverview -> onNavigateToContactImportOverview() PubkyChoiceEffect.NavigateToPayContacts -> onNavigateToPayContacts() + PubkyChoiceEffect.NavigateToProfile -> onNavigateToProfile() } } } diff --git a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt index 1328d2205c..82e96f1627 100644 --- a/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModel.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import to.bitkit.R @@ -50,6 +51,13 @@ class PubkyChoiceViewModel @Inject constructor( _uiState.update { it.copy(isWaitingForRing = false, isLoadingAfterAuth = false) } } } + viewModelScope.launch { + pubkyRepo.isAuthenticated.collectLatest { + if (it && approvalJob?.isActive != true && !_uiState.value.isLoadingAfterAuth) { + _effects.emit(PubkyChoiceEffect.NavigateToProfile) + } + } + } } override fun onCleared() { @@ -203,4 +211,5 @@ sealed interface PubkyChoiceEffect { data object NavigateToCreateProfile : PubkyChoiceEffect data object NavigateToContactImportOverview : PubkyChoiceEffect data object NavigateToPayContacts : PubkyChoiceEffect + data object NavigateToProfile : PubkyChoiceEffect } diff --git a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt index 323f6e6108..f6dd772af3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/settings/DevSettingsScreen.kt @@ -182,6 +182,13 @@ fun DevSettingsScreen( SectionHeader("DEBUG") + SettingsTextButtonRow( + title = "Reset widgets intro flag", + onClick = { + settings.setHasSeenWidgetsIntro(false) + app.toast(type = Toast.ToastType.SUCCESS, title = "Widgets intro flag reset") + } + ) SettingsTextButtonRow( title = "Generate Test Activities", onClick = { 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 4c3f2e0f2f..e91df78f28 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 @@ -158,6 +158,7 @@ import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.util.shareText import to.bitkit.ui.sheets.BackupRoute import to.bitkit.ui.sheets.PinRoute +import to.bitkit.ui.sheets.toWidgetsPreviewRoute import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors import to.bitkit.ui.theme.Insets @@ -185,6 +186,8 @@ fun HomeScreen( walletViewModel: WalletViewModel, appViewModel: AppViewModel, activityListViewModel: ActivityListViewModel, + widgetsPageRequest: Int = 0, + onConsumeWidgetsPageRequest: () -> Unit = {}, onCalculatorInputActiveChanged: (Boolean) -> Unit = {}, homeViewModel: HomeViewModel = hiltViewModel(), ) { @@ -315,21 +318,13 @@ fun HomeScreen( if (!hasSeenWidgetsIntro) { rootNavController.navigateTo(Routes.WidgetsIntro) } else { - rootNavController.navigateTo(Routes.AddWidget) + appViewModel.showSheet(Sheet.Widgets()) } }, onClickEditWidgetList = homeViewModel::onClickEditWidgetList, onClickEditWidget = { widgetType -> homeViewModel.disableEditMode() - when (widgetType) { - WidgetType.BLOCK -> rootNavController.navigateTo(Routes.BlocksPreview) - WidgetType.CALCULATOR -> rootNavController.navigateTo(Routes.CalculatorPreview) - WidgetType.FACTS -> rootNavController.navigateTo(Routes.FactsPreview) - WidgetType.NEWS -> rootNavController.navigateTo(Routes.HeadlinesPreview) - WidgetType.PRICE -> rootNavController.navigateTo(Routes.PricePreview) - WidgetType.WEATHER -> rootNavController.navigateTo(Routes.WeatherPreview) - WidgetType.SUGGESTIONS -> rootNavController.navigateTo(Routes.SuggestionsPreview) - } + appViewModel.showSheet(Sheet.Widgets(widgetType.toWidgetsPreviewRoute())) }, onClickDeleteWidget = { widgetType -> homeViewModel.displayAlertDeleteWidget(widgetType) @@ -337,6 +332,8 @@ fun HomeScreen( onMoveWidget = { fromIndex, toIndex -> homeViewModel.moveWidget(fromIndex, toIndex) }, + widgetsPageRequest = widgetsPageRequest, + onConsumeWidgetsPageRequest = onConsumeWidgetsPageRequest, onPageChanged = homeViewModel::onPageChanged, onDismissWidgetsOnboardingHint = homeViewModel::dismissWidgetsOnboardingHint, onNavigateToAppStatus = { rootNavController.navigate(Routes.AppStatus) }, @@ -349,7 +346,7 @@ fun HomeScreen( ) } -@Suppress("MagicNumber") +@Suppress("CyclomaticComplexMethod", "MagicNumber") @OptIn(ExperimentalMaterial3Api::class, ExperimentalHazeMaterialsApi::class) @Composable private fun Content( @@ -369,6 +366,8 @@ private fun Content( onClickEditWidget: (WidgetType) -> Unit = {}, onClickDeleteWidget: (WidgetType) -> Unit = {}, onMoveWidget: (Int, Int) -> Unit = { _, _ -> }, + widgetsPageRequest: Int = 0, + onConsumeWidgetsPageRequest: () -> Unit = {}, onPageChanged: (Int) -> Unit = {}, onDismissWidgetsOnboardingHint: () -> Unit = {}, onNavigateToAppStatus: () -> Unit = {}, @@ -409,6 +408,16 @@ private fun Content( } } + LaunchedEffect(widgetsPageRequest, pageCount) { + if (widgetsPageRequest == 0) return@LaunchedEffect + + if (pageCount > 1) { + pagerState.animateScrollToPage(1) + onPageChanged(1) + } + onConsumeWidgetsPageRequest() + } + LaunchedEffect(pagerState.currentPage, isCalculatorInputActive) { if (pagerState.currentPage == 0 && isCalculatorInputActive) { dismissKeyboard() @@ -764,9 +773,9 @@ private fun WidgetsPage( title = stringResource(widgetWithPosition.type.title), onClickSettings = { onClickEditWidget(widgetWithPosition.type) }, onClickDelete = { onClickDeleteWidget(widgetWithPosition.type) }, - modifier = Modifier - .fillMaxWidth(), dragModifier = dragModifier, + modifier = Modifier + .fillMaxWidth() ) } } else { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt index 3f7853ef81..aa72180333 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/send/SendAmountScreen.kt @@ -106,7 +106,7 @@ fun SendAmountScreen( onBack() }.takeIf { canGoBack }, onClickMax = { maxSats -> - if (uiState.lnurl == null) { + if (uiState.lnurl == null && uiState.payMethod == SendMethod.LIGHTNING) { app?.toast( type = Toast.ToastType.INFO, title = context.getString(R.string.wallet__send_max_spending__title), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt index 8838bb2557..e802f69e49 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/AddWidgetsScreen.kt @@ -1,141 +1,513 @@ package to.bitkit.ui.screens.widgets +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.onClick +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import kotlinx.collections.immutable.persistentListOf 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 +import to.bitkit.data.dto.price.PriceWidgetData +import to.bitkit.data.dto.price.TradingPair +import to.bitkit.models.BitcoinDisplayUnit +import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType -import to.bitkit.ui.components.FillHeight +import to.bitkit.models.widget.ArticleModel +import to.bitkit.models.widget.BlockModel +import to.bitkit.models.widget.BlocksPreferences +import to.bitkit.models.widget.PricePreferences +import to.bitkit.models.widget.WeatherDataOption +import to.bitkit.models.widget.WeatherPreferences +import to.bitkit.ui.components.BodyS import to.bitkit.ui.components.PrimaryButton -import to.bitkit.ui.components.settings.SettingsButtonRow -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.components.Subtitle +import to.bitkit.ui.screens.widgets.blocks.BlockCard +import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall +import to.bitkit.ui.screens.widgets.components.WidgetCardDimens +import to.bitkit.ui.screens.widgets.facts.FactsCardSmall +import to.bitkit.ui.screens.widgets.headlines.HeadlineCard +import to.bitkit.ui.screens.widgets.price.PriceCardSmall +import to.bitkit.ui.screens.widgets.suggestions.SuggestionsPreviewGrid +import to.bitkit.ui.screens.widgets.weather.WeatherCardSmall +import to.bitkit.ui.shared.modifiers.clickableAlpha +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable -fun AddWidgetsScreen( +fun AddWidgetsSheetContent( fiatSymbol: String, - showWidgets: Boolean = true, + showWidgets: Boolean, onWidgetSelected: (WidgetType) -> Unit, - onBackClick: () -> Unit, - onEnableInSettingsClick: () -> Unit = {}, + onEnableInSettingsClick: () -> Unit, + modifier: Modifier = Modifier, + weatherModel: WeatherModel? = null, + article: ArticleModel? = null, + block: BlockModel? = null, + fact: String? = null, ) { - ScreenColumn { - AppTopBar( + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("widgets_gallery_screen") + ) { + WidgetsGalleryList( + fiatSymbol = fiatSymbol, + showWidgets = showWidgets, titleText = stringResource(R.string.widgets__add), - onBackClick = onBackClick, - actions = { DrawerNavIcon() }, + onWidgetSelected = onWidgetSelected, + weatherModel = weatherModel, + article = article, + block = block, + fact = fact, + modifier = Modifier.weight(1f) ) + EnableWidgetsButton( + showWidgets = showWidgets, + onEnableInSettingsClick = onEnableInSettingsClick, + ) + } +} + +@Composable +private fun WidgetsGalleryList( + fiatSymbol: String, + showWidgets: Boolean, + onWidgetSelected: (WidgetType) -> Unit, + modifier: Modifier = Modifier, + titleText: String? = null, + weatherModel: WeatherModel? = null, + article: ArticleModel? = null, + block: BlockModel? = null, + fact: String? = null, +) { + Box( + modifier = modifier + .fillMaxWidth() + ) { Column( + verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(horizontal = 16.dp) + .fillMaxSize() .verticalScroll(rememberScrollState()) + .padding(horizontal = 16.dp) + .padding(bottom = Insets.Bottom + 24.dp) + .testTag("widgets_gallery_scroll") ) { - SettingsButtonRow( - title = stringResource(R.string.widgets__price__name), - subtitle = stringResource(R.string.widgets__price__description), - iconRes = R.drawable.widget_chart_line, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, - onClick = { onWidgetSelected(WidgetType.PRICE) }, - modifier = Modifier.testTag("WidgetListItem-price"), - ) - SettingsButtonRow( + titleText?.let { + GalleryTitle(titleText = it) + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + WidgetPreviewItem( + title = stringResource(R.string.widgets__price__name), + showWidgets = showWidgets, + onClick = { onWidgetSelected(WidgetType.PRICE) }, + testTag = "WidgetListItem-price", + modifier = Modifier.weight(1f) + ) { + PriceCardSmall( + pricePreferences = PreviewPricePreferences, + priceDTO = PreviewPrice, + backgroundColor = Colors.Gray6, + modifier = Modifier.smallPreviewCard() + ) + } + + WidgetPreviewItem( + title = stringResource(R.string.widgets__weather__name), + showWidgets = showWidgets, + onClick = { onWidgetSelected(WidgetType.WEATHER) }, + testTag = "WidgetListItem-weather", + modifier = Modifier.weight(1f) + ) { + WeatherCardSmall( + weatherModel = weatherModel ?: PreviewWeather, + preferences = PreviewWeatherPreferences, + modifier = Modifier.smallPreviewCard() + ) + } + } + + WidgetPreviewItem( title = stringResource(R.string.widgets__news__name), - subtitle = stringResource(R.string.widgets__news__description), - iconRes = R.drawable.widget_newspaper, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, + showWidgets = showWidgets, onClick = { onWidgetSelected(WidgetType.NEWS) }, - modifier = Modifier.testTag("WidgetListItem-news"), - ) - SettingsButtonRow( + testTag = "WidgetListItem-news", + modifier = Modifier.fillMaxWidth() + ) { + val previewArticle = article ?: PreviewArticle + HeadlineCard( + time = previewArticle.timeAgo, + headline = previewArticle.title, + source = previewArticle.publisher, + link = previewArticle.link, + enabled = showWidgets, + backgroundColor = Colors.Gray6, + modifier = Modifier + .fillMaxWidth() + .testTag("headline_card_wide") + ) + } + + WidgetPreviewItem( title = stringResource(R.string.widgets__blocks__name), - subtitle = stringResource(R.string.widgets__blocks__description), - iconRes = R.drawable.widget_cube, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, + showWidgets = showWidgets, onClick = { onWidgetSelected(WidgetType.BLOCK) }, - modifier = Modifier.testTag("WidgetListItem-blocks"), - ) - SettingsButtonRow( - title = stringResource(R.string.widgets__facts__name), - subtitle = stringResource(R.string.widgets__facts__description), - iconRes = R.drawable.widget_lightbulb, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, - onClick = { onWidgetSelected(WidgetType.FACTS) }, - modifier = Modifier.testTag("WidgetListItem-facts"), - ) - SettingsButtonRow( - title = stringResource(R.string.widgets__weather__name), - subtitle = stringResource(R.string.widgets__weather__description), - iconRes = R.drawable.widget_cloud, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, - onClick = { onWidgetSelected(WidgetType.WEATHER) }, - modifier = Modifier.testTag("WidgetListItem-weather"), - ) - SettingsButtonRow( - title = stringResource(R.string.widgets__calculator__name), - subtitle = stringResource(R.string.widgets__calculator__description).replace( - "{fiatSymbol}", - fiatSymbol - ), - iconRes = R.drawable.widget_math_operation, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, - onClick = { onWidgetSelected(WidgetType.CALCULATOR) }, - modifier = Modifier.testTag("WidgetListItem-calculator"), - ) - SettingsButtonRow( + testTag = "WidgetListItem-blocks", + modifier = Modifier.fillMaxWidth() + ) { + BlockCard( + preferences = PreviewBlocksPreferences, + block = block ?: PreviewBlock, + backgroundColor = Colors.Gray6, + ) + } + + Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) { + WidgetPreviewItem( + title = stringResource(R.string.widgets__facts__name), + showWidgets = showWidgets, + onClick = { onWidgetSelected(WidgetType.FACTS) }, + testTag = "WidgetListItem-facts", + testTagPlacement = WidgetPreviewTestTagPlacement.Title, + layoutTestTag = "WidgetListItem-facts-layout", + modifier = Modifier.weight(1f) + ) { + FactsCardSmall( + headline = fact ?: PREVIEW_FACT, + backgroundColor = Colors.Gray6, + modifier = Modifier.smallPreviewCard() + ) + } + + WidgetPreviewItem( + title = stringResource(R.string.widgets__calculator__name), + showWidgets = showWidgets, + onClick = { onWidgetSelected(WidgetType.CALCULATOR) }, + testTag = "WidgetListItem-calculator", + testTagPlacement = WidgetPreviewTestTagPlacement.Title, + layoutTestTag = "WidgetListItem-calculator-layout", + modifier = Modifier.weight(1f) + ) { + CalculatorCardSmall( + btcPrimaryDisplayUnit = BitcoinDisplayUnit.MODERN, + btcValue = PREVIEW_CALCULATOR_BTC_VALUE, + fiatSymbol = fiatSymbol, + fiatValue = PREVIEW_CALCULATOR_FIAT_VALUE, + modifier = Modifier.smallPreviewCard() + ) + } + } + + WidgetPreviewItem( title = stringResource(R.string.widgets__suggestions__name), - subtitle = stringResource(R.string.widgets__suggestions__description), - iconRes = R.drawable.widget_suggestions, - iconSize = 48.dp, - maxLinesSubtitle = 1, - enabled = showWidgets, + showWidgets = showWidgets, onClick = { onWidgetSelected(WidgetType.SUGGESTIONS) }, - modifier = Modifier.testTag("WidgetListItem-suggestions"), - ) + testTag = "WidgetListItem-suggestions", + modifier = Modifier.fillMaxWidth() + ) { + SuggestionsPreviewGrid( + suggestions = PREVIEW_SUGGESTIONS, + onSuggestionClick = { + if (showWidgets) { + onWidgetSelected(WidgetType.SUGGESTIONS) + } + }, + modifier = Modifier.fillMaxWidth() + ) + } } - FillHeight() - if (!showWidgets) { - PrimaryButton( - text = stringResource(R.string.widgets__enable_in_settings), - onClick = onEnableInSettingsClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp) + } +} + +@Composable +private fun GalleryTitle( + titleText: String, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .fillMaxWidth() + .padding(top = 32.dp, bottom = 10.dp) + .testTag("widgets_gallery_title") + ) { + Subtitle( + text = titleText, + textAlign = TextAlign.Center, + maxLines = 1, + modifier = Modifier.fillMaxWidth() + ) + } +} + +@Composable +private fun WidgetPreviewItem( + title: String, + showWidgets: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + testTag: String? = null, + testTagPlacement: WidgetPreviewTestTagPlacement = WidgetPreviewTestTagPlacement.Item, + layoutTestTag: String? = null, + content: @Composable () -> Unit, +) { + Box( + modifier = modifier + .optionalTestTag(layoutTestTag) + .widgetPreviewTestTag( + tag = testTag, + tagPlacement = testTagPlacement, + targetPlacement = WidgetPreviewTestTagPlacement.Item, + ) + .alpha(if (showWidgets) 1f else DISABLED_CARD_ALPHA) + .then( + if (showWidgets) { + Modifier.semantics { + role = Role.Button + onClick { + onClick() + true + } + } + } else { + Modifier + } + ) + ) { + Column( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + WidgetPreviewTitle( + title = title, + testTag = testTag, + testTagPlacement = testTagPlacement, + showWidgets = showWidgets, + onClick = onClick, ) + content() } + + Box( + modifier = Modifier + .matchParentSize() + .clickableAlpha( + pressedAlpha = 1f, + enabled = showWidgets, + onClick = onClick, + ) + ) + } +} + +@Composable +private fun WidgetPreviewTitle( + title: String, + testTag: String?, + testTagPlacement: WidgetPreviewTestTagPlacement, + showWidgets: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + if (testTag == null || testTagPlacement != WidgetPreviewTestTagPlacement.Title) { + BodyS( + text = title, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = modifier + ) + return } + + val titleModifier = if (showWidgets) { + modifier + .fillMaxWidth() + .testTag(testTag) + .clickableAlpha( + pressedAlpha = 1f, + onClick = onClick, + ) + } else { + modifier + .fillMaxWidth() + .testTag(testTag) + } + + Box(modifier = titleModifier) { + BodyS( + text = title, + color = Colors.White64, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private enum class WidgetPreviewTestTagPlacement { + Item, + Title, +} + +private fun Modifier.optionalTestTag(tag: String?): Modifier { + if (tag == null) return this + + return testTag(tag) +} + +private fun Modifier.widgetPreviewTestTag( + tag: String?, + tagPlacement: WidgetPreviewTestTagPlacement, + targetPlacement: WidgetPreviewTestTagPlacement, +): Modifier { + if (tag == null || tagPlacement != targetPlacement) return this + + return testTag(tag) } +@Composable +private fun EnableWidgetsButton( + showWidgets: Boolean, + onEnableInSettingsClick: () -> Unit, +) { + if (showWidgets) return + + PrimaryButton( + text = stringResource(R.string.widgets__enable_in_settings), + onClick = onEnableInSettingsClick, + modifier = Modifier + .fillMaxWidth() + .padding( + start = 16.dp, + end = 16.dp, + top = 8.dp, + bottom = Insets.Bottom + 16.dp, + ) + .testTag("WidgetEnableInSettings") + ) +} + +private fun Modifier.smallPreviewCard(): Modifier = + fillMaxWidth().height(WidgetCardDimens.COMPACT_CARD_SIZE.height) + +private val PreviewPricePreferences = PricePreferences( + enabledPairs = listOf(TradingPair.BTC_USD), + period = GraphPeriod.ONE_DAY, +) + +private val PreviewPrice = PriceDTO( + widgets = listOf( + PriceWidgetData( + pair = TradingPair.BTC_USD, + period = GraphPeriod.ONE_DAY, + change = Change(isPositive = true, formatted = "+1.24%"), + price = "75,326", + pastValues = listOf( + 1.0, + 2.3, + 1.4, + 1.8, + 4.9, + 2.7, + 3.2, + 2.5, + 6.3, + 5.8, + 3.9, + 7.0, + ), + ), + ), +) + +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 = "$ 0.52", + currentFeeSats = 52, + currentFeeSatsFormatted = "52 sats/vByte", + nextBlockFee = "2 sats/vByte", + icon = FeeCondition.GOOD.icon, +) + +private val PreviewWeatherPreferences = WeatherPreferences( + selectedOption = WeatherDataOption.CURRENT_FEE_FIAT, +) + +private val PreviewBlock = BlockModel( + height = "761,405", + time = "01:31:42 UTC", + date = "11/2/2022", + transactionCount = "2,175", + size = "1,606kb", + fees = "25 059 357", +) + +private val PreviewBlocksPreferences = BlocksPreferences( + showBlock = true, + showTime = true, + showDate = true, + showTransactions = true, + showSize = false, + showFees = false, +) + +private const val PREVIEW_CALCULATOR_BTC_VALUE = "10000" +private const val PREVIEW_CALCULATOR_FIAT_VALUE = "4.55" +private const val PREVIEW_FACT = "Bitcoin doesn’t need your personal information" +private val PreviewArticle = ArticleModel( + timeAgo = "21 min ago", + title = "How Bitcoin Changed El Salvador In More Ways...", + publisher = "bitcoinmagazine.com", + link = "https://bitcoinmagazine.com", +) +private val PREVIEW_SUGGESTIONS = persistentListOf( + Suggestion.BACK_UP, + Suggestion.SECURE, + Suggestion.LIGHTNING, + Suggestion.SUPPORT, +) +private const val DISABLED_CARD_ALPHA = 0.42f + @Preview(showSystemUi = true) @Composable -private fun Preview() { +private fun PreviewSheet() { AppThemeSurface { - AddWidgetsScreen( + AddWidgetsSheetContent( onWidgetSelected = {}, fiatSymbol = "$", - onBackClick = {}, + showWidgets = true, + onEnableInSettingsClick = {}, ) } } @@ -144,11 +516,11 @@ private fun Preview() { @Composable private fun PreviewDisabled() { AppThemeSurface { - AddWidgetsScreen( + AddWidgetsSheetContent( onWidgetSelected = {}, fiatSymbol = "$", - onBackClick = {}, showWidgets = false, + onEnableInSettingsClick = {}, ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt index 03a9e20c02..ba08324cbe 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/WidgetsIntroScreen.kt @@ -1,7 +1,9 @@ package to.bitkit.ui.screens.widgets import androidx.compose.foundation.Image +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 @@ -17,6 +19,7 @@ import to.bitkit.R import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.Display import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.scaffold.AppTopBar import to.bitkit.ui.scaffold.DrawerNavIcon import to.bitkit.ui.scaffold.ScreenColumn @@ -26,18 +29,21 @@ import to.bitkit.ui.utils.withAccent @Composable fun WidgetsIntroScreen( - onContinue: () -> Unit, + onViewOrganize: () -> Unit, + onAddWidget: () -> Unit, onBackClick: () -> Unit, ) { ScreenColumn { AppTopBar( - titleText = "", + titleText = stringResource(R.string.widgets__widgets), onBackClick = onBackClick, actions = { DrawerNavIcon() }, ) Column( - modifier = Modifier.padding(horizontal = 32.dp) + modifier = Modifier + .weight(1f) + .padding(horizontal = 32.dp) ) { Image( painter = painterResource(R.drawable.puzzle), @@ -54,13 +60,33 @@ fun WidgetsIntroScreen( Spacer(Modifier.height(8.dp)) BodyM(text = stringResource(R.string.widgets__onboarding__description), color = Colors.White64) Spacer(Modifier.height(32.dp)) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.widgets__onboarding__view_organize), + onClick = onViewOrganize, + fullWidth = false, + modifier = Modifier + .weight(1f) + .testTag("WidgetsOnboarding-view-organize") + ) PrimaryButton( - text = stringResource(R.string.common__continue), - onClick = onContinue, - modifier = Modifier.testTag("WidgetsOnboarding-button") + text = stringResource(R.string.widgets__add), + onClick = onAddWidget, + fullWidth = false, + modifier = Modifier + .weight(1f) + .testTag("WidgetsOnboarding-button") ) - Spacer(Modifier.height(16.dp)) } + + Spacer(Modifier.height(16.dp)) } } @@ -69,7 +95,8 @@ fun WidgetsIntroScreen( private fun Preview() { AppThemeSurface { WidgetsIntroScreen( - onContinue = {}, + onViewOrganize = {}, + onAddWidget = {}, onBackClick = {} ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt index 78bbd9b6b3..bb270941f4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlockCard.kt @@ -16,6 +16,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -35,6 +36,7 @@ import to.bitkit.ui.theme.Colors @Composable fun BlockCard( modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -45,7 +47,7 @@ fun BlockCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -68,6 +70,7 @@ fun BlockCard( @Composable fun BlockCardSmall( modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, preferences: BlocksPreferences, block: BlockModel, ) { @@ -79,7 +82,7 @@ fun BlockCardSmall( modifier = modifier .size(WidgetCardDimens.COMPACT_CARD_SIZE) .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt index 87956c18ca..1a88ba6ee0 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksEditScreen.kt @@ -4,6 +4,7 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -31,10 +32,11 @@ 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.scaffold.AppTopBar -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun BlocksEditScreen( @@ -76,12 +78,15 @@ private fun Content( block: BlockModel, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("blocks_edit_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("blocks_edit_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__blocks__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -115,7 +120,10 @@ private fun Content( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp) + .padding( + top = 21.dp, + bottom = Insets.Bottom + 21.dp, + ) .fillMaxWidth() .testTag("buttons_row") ) { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt index b79c060686..8c4e25aa2a 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksPreviewScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.blocks import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -24,11 +25,12 @@ import to.bitkit.ui.components.SecondaryButton 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.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun BlocksPreviewScreen( @@ -53,12 +55,10 @@ fun BlocksPreviewScreen( block = currentBlock, onClickEdit = navigateEditWidget, onClickDelete = { - blocksViewModel.removeWidget() - onClose() + blocksViewModel.removeWidget(onComplete = onClose) }, onClickSave = { - blocksViewModel.savePreferences() - onClose() + blocksViewModel.savePreferences(onComplete = onClose) }, modifier = modifier ) @@ -75,12 +75,15 @@ private fun Content( block: BlockModel?, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("blocks_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("blocks_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__blocks__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -146,7 +149,7 @@ private fun Content( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt index 0cbdbf6081..8fc56aeae2 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/blocks/BlocksViewModel.kt @@ -74,16 +74,18 @@ class BlocksViewModel @Inject constructor( _customPreferences.value = BlocksPreferences() } - fun savePreferences() { + fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateBlocksPreferences(_customPreferences.value) widgetsRepo.addWidget(WidgetType.BLOCK) + onComplete() } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.BLOCK) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt index 116a96f0f8..aaf6203d7f 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorPreviewScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.calculator import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -22,13 +23,14 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardEditor import to.bitkit.ui.screens.widgets.calculator.components.CalculatorCardSmall import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun CalculatorPreviewScreen( @@ -49,12 +51,10 @@ fun CalculatorPreviewScreen( onInputSelected = viewModel::onInputSelected, onInputDismissed = viewModel::onInputDismissed, onClickDelete = { - viewModel.removeWidget() - onClose() + viewModel.removeWidget(onComplete = onClose) }, onClickSave = { - viewModel.saveWidget() - onClose() + viewModel.saveWidget(onComplete = onClose) }, modifier = modifier ) @@ -73,12 +73,15 @@ fun CalculatorPreviewContent( onInputSelected: (MoneyType) -> Unit = {}, onInputDismissed: () -> Unit = {}, ) { - ScreenColumn( - modifier = modifier.testTag("calculator_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("calculator_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__calculator__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -141,7 +144,7 @@ fun CalculatorPreviewContent( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt index d48db22f95..7bfe0752ec 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/calculator/CalculatorViewModel.kt @@ -63,15 +63,17 @@ class CalculatorViewModel @Inject constructor( observeCalculatorState() } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.CALCULATOR) + onComplete() } } - fun saveWidget() { + fun saveWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.addWidget(WidgetType.CALCULATOR) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt index 746e29bea9..0d422750bc 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsCard.kt @@ -33,11 +33,12 @@ import to.bitkit.ui.theme.Colors fun FactsCard( headline: String, modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, ) { Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Row( horizontalArrangement = Arrangement.spacedBy(20.dp), @@ -63,12 +64,13 @@ fun FactsCard( fun FactsCardSmall( headline: String, modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, ) { Box( modifier = modifier .size(WidgetCardDimens.COMPACT_CARD_SIZE) .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Column( modifier = Modifier @@ -112,10 +114,10 @@ private fun BitcoinBadge(modifier: Modifier = Modifier) { private fun PreviewWide() { AppThemeSurface { Column( + verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier .fillMaxSize() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp) + .padding(16.dp) ) { FactsCard( headline = "Bitcoin doesn’t need your personal information", diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt index ced880e8a0..d8d787b164 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsPreviewScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.facts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -20,11 +21,12 @@ import to.bitkit.ui.components.BodyM import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun FactsPreviewScreen( @@ -45,12 +47,10 @@ fun FactsPreviewScreen( isFactsWidgetEnabled = isFactsWidgetEnabled, fact = fact, onClickDelete = { - factsViewModel.removeWidget() - onClose() + factsViewModel.removeWidget(onComplete = onClose) }, onClickSave = { - factsViewModel.saveWidget() - onClose() + factsViewModel.saveWidget(onComplete = onClose) }, modifier = modifier ) @@ -65,12 +65,15 @@ fun FactsPreviewContent( fact: String, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("facts_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("facts_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__facts__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -119,7 +122,7 @@ fun FactsPreviewContent( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt index c1ddd9dda9..f3751e0ee1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/facts/FactsViewModel.kt @@ -34,15 +34,17 @@ class FactsViewModel @Inject constructor( initialValue = DEFAULT_FACT ) - fun saveWidget() { + fun saveWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.addWidget(WidgetType.FACTS) + onComplete() } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.FACTS) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt index acb7327268..43899fbcf4 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlineCard.kt @@ -14,6 +14,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextOverflow @@ -30,20 +31,22 @@ import to.bitkit.ui.theme.Colors @Composable fun HeadlineCard( modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, showTime: Boolean = true, showSource: Boolean = true, time: String, headline: String, source: String, link: String, + enabled: Boolean = true, ) { val context = LocalContext.current Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) - .clickableAlpha { + .background(backgroundColor) + .clickableAlpha(enabled = enabled) { val uri = safeBrowserUri(link) ?: return@clickableAlpha val intent = Intent(Intent.ACTION_VIEW, uri) context.startActivity(intent) @@ -93,6 +96,7 @@ fun HeadlineCard( @Composable fun HeadlineCardSmall( modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, showTime: Boolean = true, time: String, headline: String, @@ -104,7 +108,7 @@ fun HeadlineCardSmall( modifier = modifier .size(WidgetCardDimens.COMPACT_CARD_SIZE) .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) .clickableAlpha { val uri = safeBrowserUri(link) ?: return@clickableAlpha val intent = Intent(Intent.ACTION_VIEW, uri) diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditScreen.kt index 6d47f628c0..a6a5374435 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesEditScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.headlines import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -29,10 +30,11 @@ import to.bitkit.ui.components.PrimaryButton import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.Title import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun HeadlinesEditScreen( @@ -75,12 +77,15 @@ fun HeadlinesEditContent( article: ArticleModel, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("headlines_edit_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("headlines_edit_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__news__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -206,11 +211,14 @@ fun HeadlinesEditContent( FillHeight() Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp) + .padding( + top = 21.dp, + bottom = Insets.Bottom + 21.dp, + ) .fillMaxWidth() - .testTag("buttons_row"), - horizontalArrangement = Arrangement.spacedBy(16.dp) + .testTag("buttons_row") ) { SecondaryButton( text = stringResource(R.string.common__reset), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt index ed2b628b1e..ced65456b1 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesPreviewScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.headlines import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -24,11 +25,12 @@ import to.bitkit.ui.components.SecondaryButton 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.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun HeadlinesPreviewScreen( @@ -53,12 +55,10 @@ fun HeadlinesPreviewScreen( article = article, onClickEdit = navigateEditWidget, onClickDelete = { - headlinesViewModel.removeWidget() - onClose() + headlinesViewModel.removeWidget(onComplete = onClose) }, onClickSave = { - headlinesViewModel.savePreferences() - onClose() + headlinesViewModel.savePreferences(onComplete = onClose) }, modifier = modifier ) @@ -75,12 +75,15 @@ fun HeadlinesPreviewContent( article: ArticleModel, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("headlines_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("headlines_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__news__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -150,7 +153,7 @@ fun HeadlinesPreviewContent( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt index 6ee64e0091..e8227ba646 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/headlines/HeadlinesViewModel.kt @@ -78,16 +78,18 @@ class HeadlinesViewModel @Inject constructor( _customPreferences.value = HeadlinePreferences() } - fun savePreferences() { + fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateHeadlinePreferences(_customPreferences.value) widgetsRepo.addWidget(WidgetType.NEWS) + onComplete() } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.NEWS) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt index 8aeaba439c..ac726b6e5b 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceCard.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalInspectionMode @@ -54,6 +55,7 @@ fun PriceCard( pricePreferences: PricePreferences, priceDTO: PriceDTO, modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, ) { val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { priceDTO.resolveWidget(pricePreferences) @@ -62,7 +64,7 @@ fun PriceCard( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -115,6 +117,7 @@ fun PriceCardSmall( pricePreferences: PricePreferences, priceDTO: PriceDTO, modifier: Modifier = Modifier, + backgroundColor: Color = Colors.White10, ) { val widgetData = remember(pricePreferences.enabledPairs, priceDTO.widgets) { priceDTO.resolveWidget(pricePreferences) @@ -123,7 +126,7 @@ fun PriceCardSmall( Box( modifier = modifier .clip(shape = MaterialTheme.shapes.medium) - .background(Colors.White10) + .background(backgroundColor) ) { Column( verticalArrangement = Arrangement.spacedBy(16.dp), diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt index 39b4beddcf..b3bfa5b8d8 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceEditScreen.kt @@ -38,10 +38,11 @@ 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.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun PriceEditScreen( @@ -78,8 +79,11 @@ fun PriceEditContent( ) { val selectedPair = preferences.enabledPairs.firstOrNull() ?: TradingPair.BTC_USD - ScreenColumn( - modifier = modifier.testTag("price_edit_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("price_edit_screen") ) { Box( modifier = Modifier @@ -129,9 +133,9 @@ fun PriceEditContent( } Column { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__price__name), - onBackClick = onBack, + onBack = onBack, modifier = Modifier.background( Brush.verticalGradient( colors = listOf( @@ -148,7 +152,12 @@ fun PriceEditContent( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(16.dp) + .padding( + start = 16.dp, + end = 16.dp, + top = 16.dp, + bottom = Insets.Bottom + 16.dp, + ) .fillMaxWidth() .testTag("buttons_row") ) { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt index 7a028b8895..26db408d78 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PricePreviewScreen.kt @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.price import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -30,12 +31,13 @@ import to.bitkit.ui.components.SecondaryButton 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.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.components.WidgetCardDimens import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun PricePreviewScreen( @@ -70,8 +72,7 @@ fun PricePreviewScreen( priceDTO = previewPrice ?: price, onClickEdit = navigateEditWidget, onClickDelete = { - priceViewModel.removeWidget() - onClose() + priceViewModel.removeWidget(onComplete = onClose) }, onClickSave = { priceViewModel.savePreferences() @@ -93,12 +94,15 @@ fun PricePreviewContent( isLoading: Boolean, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("price_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("price_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__price__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -169,7 +173,7 @@ fun PricePreviewContent( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt index 29b4c49ac9..8a17a774ca 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/price/PriceViewModel.kt @@ -107,9 +107,10 @@ class PriceViewModel @Inject constructor( } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.PRICE) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreen.kt index 62d16e8fd7..f6db3ce468 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsPreviewScreen.kt @@ -1,9 +1,12 @@ package to.bitkit.ui.screens.widgets.suggestions import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells @@ -16,12 +19,15 @@ 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 kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf import to.bitkit.R import to.bitkit.ext.spaceToNewline import to.bitkit.models.Suggestion @@ -33,19 +39,25 @@ import to.bitkit.ui.components.SecondaryButton import to.bitkit.ui.components.SuggestionCard import to.bitkit.ui.components.Text13Up import to.bitkit.ui.components.VerticalSpacer -import to.bitkit.ui.scaffold.AppTopBar -import to.bitkit.ui.scaffold.DrawerNavIcon -import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets -private val previewSuggestions = listOf(Suggestion.BUY, Suggestion.BACK_UP) +private val previewSuggestions = persistentListOf( + Suggestion.BACK_UP, + Suggestion.SECURE, + Suggestion.LIGHTNING, + Suggestion.SUPPORT, +) @Composable fun SuggestionsPreviewScreen( suggestionsViewModel: SuggestionsViewModel, onClose: () -> Unit, onBack: () -> Unit, + modifier: Modifier = Modifier, ) { val isSuggestionsWidgetEnabled by suggestionsViewModel.isSuggestionsWidgetEnabled .collectAsStateWithLifecycle() @@ -54,13 +66,12 @@ fun SuggestionsPreviewScreen( onBack = onBack, isSuggestionsWidgetEnabled = isSuggestionsWidgetEnabled, onClickDelete = { - suggestionsViewModel.removeWidget() - onClose() + suggestionsViewModel.removeWidget(onComplete = onClose) }, onClickSave = { - suggestionsViewModel.addWidget() - onClose() + suggestionsViewModel.addWidget(onComplete = onClose) }, + modifier = modifier ) } @@ -70,12 +81,17 @@ private fun Content( onBack: () -> Unit, onClickDelete: () -> Unit, onClickSave: () -> Unit, + modifier: Modifier = Modifier, ) { - ScreenColumn { - AppTopBar( - titleText = stringResource(R.string.widgets__widget__nav_title), - onBackClick = onBack, - actions = { DrawerNavIcon() }, + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("suggestions_preview_screen") + ) { + SheetTopBar( + titleText = stringResource(R.string.widgets__suggestions__name), + onBack = onBack, ) Column( @@ -86,7 +102,7 @@ private fun Content( Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.SpaceBetween, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth() ) { Headline( text = AnnotatedString(stringResource(R.string.widgets__suggestions__name).spaceToNewline()), @@ -115,40 +131,28 @@ private fun Content( modifier = Modifier.padding(vertical = 16.dp) ) - LazyVerticalGrid( - columns = GridCells.Fixed(2), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - userScrollEnabled = false, + SuggestionsPreviewGrid( + onSuggestionClick = {}, modifier = Modifier.fillMaxWidth() - ) { - items( - items = previewSuggestions, - key = { it.name } - ) { item -> - SuggestionCard( - gradientColor = item.color, - title = stringResource(item.title), - description = stringResource(item.description), - icon = item.icon, - disableGlow = true, - onClick = {}, - ) - } - } + ) Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp) - .fillMaxWidth(), + .padding( + top = 21.dp, + bottom = Insets.Bottom + 21.dp, + ) + .fillMaxWidth() ) { if (isSuggestionsWidgetEnabled) { SecondaryButton( text = stringResource(R.string.common__delete), fullWidth = false, onClick = onClickDelete, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("WidgetDelete") ) } @@ -156,7 +160,48 @@ private fun Content( text = stringResource(R.string.common__save), fullWidth = false, onClick = onClickSave, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .testTag("WidgetSave") + ) + } + } + } +} + +@Composable +fun SuggestionsPreviewGrid( + onSuggestionClick: (Suggestion) -> Unit, + modifier: Modifier = Modifier, + suggestions: ImmutableList = previewSuggestions, +) { + val rows = (suggestions.size + 1) / 2 + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val cardSize = (maxWidth - 16.dp) / 2 + val gridHeight = (cardSize * rows) + (16.dp * (rows - 1)) + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + userScrollEnabled = false, + modifier = Modifier + .fillMaxWidth() + .height(gridHeight) + ) { + items( + items = suggestions, + key = { it.name }, + ) { item -> + SuggestionCard( + gradientColor = item.color, + title = stringResource(item.title), + description = stringResource(item.description), + icon = item.icon, + disableGlow = true, + onClick = { onSuggestionClick(item) }, + modifier = Modifier.testTag("Suggestion-${item.name.lowercase()}") ) } } diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsViewModel.kt index bc3ea89190..4bc29cc33d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/widgets/suggestions/SuggestionsViewModel.kt @@ -27,15 +27,17 @@ class SuggestionsViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.WhileSubscribed(SUBSCRIBE_TIMEOUT), false) - fun addWidget() { + fun addWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.addWidget(WidgetType.SUGGESTIONS) + onComplete() } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.SUGGESTIONS) + onComplete() } } } 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 61d6753086..277e740f62 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 @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.weather import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -30,11 +31,12 @@ 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.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.blocks.WeatherModel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun WeatherEditScreen( @@ -67,12 +69,15 @@ fun WeatherEditContent( weatherPreferences: WeatherPreferences, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("weather_edit_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("weather_edit_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__weather__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -119,7 +124,10 @@ fun WeatherEditContent( Row( horizontalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier - .padding(vertical = 21.dp) + .padding( + top = 21.dp, + bottom = Insets.Bottom + 21.dp, + ) .fillMaxWidth() .testTag("buttons_row") ) { diff --git a/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt b/app/src/main/java/to/bitkit/ui/screens/widgets/weather/WeatherPreviewScreen.kt index 1dc13223f7..abc737ee44 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 @@ -3,6 +3,7 @@ package to.bitkit.ui.screens.widgets.weather import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.material3.HorizontalDivider @@ -25,12 +26,13 @@ import to.bitkit.ui.components.SecondaryButton 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.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar import to.bitkit.ui.screens.widgets.blocks.WeatherModel import to.bitkit.ui.screens.widgets.components.WidgetSizeCarousel +import to.bitkit.ui.shared.util.gradientBackground import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.Insets @Composable fun WeatherPreviewScreen( @@ -55,12 +57,10 @@ fun WeatherPreviewScreen( weatherModel = weather, onClickEdit = navigateEditWidget, onClickDelete = { - weatherViewModel.removeWidget() - onClose() + weatherViewModel.removeWidget(onComplete = onClose) }, onClickSave = { - weatherViewModel.savePreferences() - onClose() + weatherViewModel.savePreferences(onComplete = onClose) }, modifier = modifier ) @@ -77,12 +77,15 @@ fun WeatherPreviewContent( weatherModel: WeatherModel?, modifier: Modifier = Modifier, ) { - ScreenColumn( - modifier = modifier.testTag("weather_preview_screen") + Column( + modifier = modifier + .fillMaxSize() + .gradientBackground() + .testTag("weather_preview_screen") ) { - AppTopBar( + SheetTopBar( titleText = stringResource(R.string.widgets__weather__name), - onBackClick = onBack, + onBack = onBack, ) Column( @@ -148,7 +151,7 @@ fun WeatherPreviewContent( .padding( start = 16.dp, end = 16.dp, - bottom = 16.dp, + bottom = Insets.Bottom + 16.dp, top = 22.dp, ) .fillMaxWidth() 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 c52db8507c..24183c0cef 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 @@ -91,16 +91,18 @@ class WeatherViewModel @Inject constructor( _customPreferences.value = WeatherPreferences() } - fun savePreferences() { + fun savePreferences(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.updateWeatherPreferences(_customPreferences.value) widgetsRepo.addWidget(WidgetType.WEATHER) + onComplete() } } - fun removeWidget() { + fun removeWidget(onComplete: () -> Unit = {}) { viewModelScope.launch { widgetsRepo.deleteWidget(WidgetType.WEATHER) + onComplete() } } diff --git a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt index 26b091c68a..8309cc101e 100644 --- a/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt +++ b/app/src/main/java/to/bitkit/ui/shared/modifiers/SheetHeight.kt @@ -18,8 +18,10 @@ fun Modifier.sheetHeight( size: SheetSize = SheetSize.LARGE, isModal: Boolean = false, ): Modifier = composed { - val offset = if (isModal) Insets.Bottom else 0.dp - val topPadding = Insets.Top + Insets.Bottom + offset + TopBarHeight - 6.dp + // Bottom safe-area belongs in sheet content padding; including it here moves + // non-modal sheet tops down on devices with larger navigation-bar insets. + val modalBottomPadding = if (isModal) Insets.Bottom + Insets.Bottom else 0.dp + val topPadding = Insets.Top + modalBottomPadding + TopBarHeight - 6.dp val height = when (size) { SheetSize.LARGE -> screenHeight(minus = topPadding) // topbar visible diff --git a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt index 2386aae6ce..1f54c44421 100644 --- a/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt +++ b/app/src/main/java/to/bitkit/ui/sheets/SendSheet.kt @@ -230,7 +230,8 @@ fun SendSheet( isNodeRunning = lightningState.nodeLifecycleState.isRunning(), canGoBack = startDestination != SendRoute.Confirm, onBack = { - if (!navController.popBackStack()) { + val didPopToAmount = navController.popBackStack(SendRoute.Amount, inclusive = false) + if (!didPopToAmount && !navController.popBackStack()) { appViewModel.hideSheet() } }, diff --git a/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt new file mode 100644 index 0000000000..1cc6b2ac90 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/sheets/WidgetsSheet.kt @@ -0,0 +1,331 @@ +package to.bitkit.ui.sheets + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.tooling.preview.Preview +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.HasDefaultViewModelProviderFactory +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.CreationExtras +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.navigation.NavHostController +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.rememberNavController +import kotlinx.serialization.Serializable +import to.bitkit.models.WidgetType +import to.bitkit.ui.components.BottomSheetPreview +import to.bitkit.ui.components.Sheet +import to.bitkit.ui.components.SheetSize +import to.bitkit.ui.navigateTo +import to.bitkit.ui.screens.widgets.AddWidgetsSheetContent +import to.bitkit.ui.screens.widgets.blocks.BlocksEditScreen +import to.bitkit.ui.screens.widgets.blocks.BlocksPreviewScreen +import to.bitkit.ui.screens.widgets.blocks.BlocksViewModel +import to.bitkit.ui.screens.widgets.calculator.CalculatorPreviewScreen +import to.bitkit.ui.screens.widgets.facts.FactsPreviewScreen +import to.bitkit.ui.screens.widgets.facts.FactsViewModel +import to.bitkit.ui.screens.widgets.headlines.HeadlinesEditScreen +import to.bitkit.ui.screens.widgets.headlines.HeadlinesPreviewScreen +import to.bitkit.ui.screens.widgets.headlines.HeadlinesViewModel +import to.bitkit.ui.screens.widgets.price.PriceEditScreen +import to.bitkit.ui.screens.widgets.price.PricePreviewScreen +import to.bitkit.ui.screens.widgets.price.PriceViewModel +import to.bitkit.ui.screens.widgets.suggestions.SuggestionsPreviewScreen +import to.bitkit.ui.screens.widgets.suggestions.SuggestionsViewModel +import to.bitkit.ui.screens.widgets.weather.WeatherEditScreen +import to.bitkit.ui.screens.widgets.weather.WeatherPreviewScreen +import to.bitkit.ui.screens.widgets.weather.WeatherViewModel +import to.bitkit.ui.shared.modifiers.sheetHeight +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.utils.composableWithDefaultTransitions +import to.bitkit.viewmodels.AppViewModel + +@Composable +fun WidgetsSheet( + sheet: Sheet.Widgets, + app: AppViewModel, + fiatSymbol: String, + showWidgets: Boolean, + onNavigateHomeWidgets: () -> Unit, + onOpenWidgetsSettings: () -> Unit, +) { + val navController = rememberNavController() + val onDismiss = app::hideSheet + val onDone = { + app.hideSheet() + onNavigateHomeWidgets() + } + + WidgetsSheetContent( + startRoute = sheet.route, + navController = navController, + fiatSymbol = fiatSymbol, + showWidgets = showWidgets, + onDismiss = onDismiss, + onDone = onDone, + onOpenWidgetsSettings = { + app.hideSheet() + onOpenWidgetsSettings() + }, + ) +} + +@Composable +private fun WidgetsSheetContent( + startRoute: WidgetsRoute, + navController: NavHostController, + fiatSymbol: String, + showWidgets: Boolean, + onDismiss: () -> Unit, + onDone: () -> Unit, + onOpenWidgetsSettings: () -> Unit, +) { + val sheetViewModelStoreOwner = rememberSheetViewModelStoreOwner() + val weatherViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + val blocksViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + val headlinesViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + val factsViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + val suggestionsViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE) + .gradientBackground() + .testTag("widgets_navigation_sheet") + ) { + NavHost( + navController = navController, + startDestination = startRoute, + modifier = Modifier.fillMaxSize() + ) { + composableWithDefaultTransitions { + val weather by weatherViewModel.currentWeather.collectAsStateWithLifecycle() + val block by blocksViewModel.currentBlock.collectAsStateWithLifecycle() + val article by headlinesViewModel.currentArticle.collectAsStateWithLifecycle() + val fact by factsViewModel.currentFact.collectAsStateWithLifecycle() + + LaunchedEffect(Unit) { + weatherViewModel.refreshOnDisplay() + blocksViewModel.refreshOnDisplay() + headlinesViewModel.refreshOnDisplay() + factsViewModel.refreshOnDisplay() + } + + AddWidgetsSheetContent( + fiatSymbol = fiatSymbol, + showWidgets = showWidgets, + onWidgetSelected = { + navController.navigateTo(it.toWidgetsPreviewRoute()) + }, + onEnableInSettingsClick = onOpenWidgetsSettings, + weatherModel = weather, + article = article, + block = block, + fact = fact, + ) + } + composableWithDefaultTransitions { + val priceViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + + PricePreviewScreen( + priceViewModel = priceViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + navigateEditWidget = { navController.navigateTo(WidgetsRoute.PriceEdit) }, + ) + } + composableWithDefaultTransitions { + val priceViewModel = hiltViewModel(viewModelStoreOwner = sheetViewModelStoreOwner) + + PriceEditScreen( + viewModel = priceViewModel, + onBack = { navController.popOrDismiss(onDismiss) }, + navigatePreview = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + WeatherPreviewScreen( + weatherViewModel = weatherViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + navigateEditWidget = { navController.navigateTo(WidgetsRoute.WeatherEdit) }, + ) + } + composableWithDefaultTransitions { + WeatherEditScreen( + weatherViewModel = weatherViewModel, + onBack = { navController.popOrDismiss(onDismiss) }, + navigatePreview = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + BlocksPreviewScreen( + blocksViewModel = blocksViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + navigateEditWidget = { navController.navigateTo(WidgetsRoute.BlocksEdit) }, + ) + } + composableWithDefaultTransitions { + BlocksEditScreen( + blocksViewModel = blocksViewModel, + onBack = { navController.popOrDismiss(onDismiss) }, + navigatePreview = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + HeadlinesPreviewScreen( + headlinesViewModel = headlinesViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + navigateEditWidget = { navController.navigateTo(WidgetsRoute.HeadlinesEdit) }, + ) + } + composableWithDefaultTransitions { + HeadlinesEditScreen( + headlinesViewModel = headlinesViewModel, + onBack = { navController.popOrDismiss(onDismiss) }, + navigatePreview = { navController.popBackStack() }, + ) + } + composableWithDefaultTransitions { + FactsPreviewScreen( + factsViewModel = factsViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + ) + } + composableWithDefaultTransitions { + CalculatorPreviewScreen( + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + ) + } + composableWithDefaultTransitions { + SuggestionsPreviewScreen( + suggestionsViewModel = suggestionsViewModel, + onClose = onDone, + onBack = { navController.popOrDismiss(onDismiss) }, + ) + } + } + } +} + +@Composable +private fun rememberSheetViewModelStoreOwner(): ViewModelStoreOwner { + val parentOwner = checkNotNull(LocalViewModelStoreOwner.current) { + "No ViewModelStoreOwner was provided via LocalViewModelStoreOwner" + } + val parentFactoryOwner = checkNotNull(parentOwner as? HasDefaultViewModelProviderFactory) { + "WidgetsSheet requires a default ViewModelProvider.Factory owner" + } + val viewModelStore = remember { ViewModelStore() } + DisposableEffect(viewModelStore) { + onDispose { viewModelStore.clear() } + } + + return remember(viewModelStore, parentFactoryOwner) { + SheetViewModelStoreOwner(viewModelStore, parentFactoryOwner) + } +} + +private class SheetViewModelStoreOwner( + private val store: ViewModelStore, + private val factoryOwner: HasDefaultViewModelProviderFactory, +) : ViewModelStoreOwner, HasDefaultViewModelProviderFactory { + override val viewModelStore: ViewModelStore = store + override val defaultViewModelProviderFactory: ViewModelProvider.Factory + get() = factoryOwner.defaultViewModelProviderFactory + override val defaultViewModelCreationExtras: CreationExtras + get() = factoryOwner.defaultViewModelCreationExtras +} + +private fun NavHostController.popOrDismiss(onDismiss: () -> Unit) { + if (!popBackStack()) { + onDismiss() + } +} + +fun WidgetType.toWidgetsPreviewRoute(): WidgetsRoute = when (this) { + WidgetType.BLOCK -> WidgetsRoute.BlocksPreview + WidgetType.CALCULATOR -> WidgetsRoute.CalculatorPreview + WidgetType.FACTS -> WidgetsRoute.FactsPreview + WidgetType.NEWS -> WidgetsRoute.HeadlinesPreview + WidgetType.PRICE -> WidgetsRoute.PricePreview + WidgetType.WEATHER -> WidgetsRoute.WeatherPreview + WidgetType.SUGGESTIONS -> WidgetsRoute.SuggestionsPreview +} + +sealed interface WidgetsRoute { + @Serializable + data object Gallery : WidgetsRoute + + @Serializable + data object PricePreview : WidgetsRoute + + @Serializable + data object PriceEdit : WidgetsRoute + + @Serializable + data object WeatherPreview : WidgetsRoute + + @Serializable + data object WeatherEdit : WidgetsRoute + + @Serializable + data object BlocksPreview : WidgetsRoute + + @Serializable + data object BlocksEdit : WidgetsRoute + + @Serializable + data object HeadlinesPreview : WidgetsRoute + + @Serializable + data object HeadlinesEdit : WidgetsRoute + + @Serializable + data object FactsPreview : WidgetsRoute + + @Serializable + data object CalculatorPreview : WidgetsRoute + + @Serializable + data object SuggestionsPreview : WidgetsRoute +} + +@Preview(showSystemUi = true) +@Composable +private fun Preview() { + AppThemeSurface { + BottomSheetPreview { + Column( + modifier = Modifier + .fillMaxWidth() + .sheetHeight(SheetSize.LARGE, isModal = true) + .gradientBackground() + ) { + AddWidgetsSheetContent( + fiatSymbol = "$", + showWidgets = true, + onWidgetSelected = {}, + onEnableInSettingsClick = {}, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 45098e490b..9ba0b7a597 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1208,6 +1208,7 @@ Bitcoin Headlines Enjoy decentralized feeds from your favorite web services, by adding fun and useful widgets to your Bitkit wallet. Hello,\n<accent>Widgets</accent> + View & Organize Swipe to find\n<accent>your widgets</accent> Check the latest Bitcoin exchange rates for a variety of fiat currencies. Bitcoin Price diff --git a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt index 2f6d672cc3..a6c4c94788 100644 --- a/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/BackupRepoTest.kt @@ -2,19 +2,29 @@ package to.bitkit.repositories import android.content.Context import com.synonym.vssclient.VssItem +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent import org.junit.Before import org.junit.Test import org.mockito.kotlin.any import org.mockito.kotlin.anyOrNull +import org.mockito.kotlin.doSuspendableAnswer +import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.whenever +import to.bitkit.data.AppCacheData import to.bitkit.data.AppDb import to.bitkit.data.CacheStore import to.bitkit.data.SettingsData import to.bitkit.data.SettingsStore +import to.bitkit.data.WidgetsData import to.bitkit.data.WidgetsStore import to.bitkit.data.backup.VssBackupClient import to.bitkit.data.backup.VssBackupClientLdk @@ -22,6 +32,7 @@ import to.bitkit.data.dao.TransferDao import to.bitkit.data.entities.TransferEntity import to.bitkit.di.json import to.bitkit.models.BackupCategory +import to.bitkit.models.BackupItemStatus import to.bitkit.models.PrivatePaykitContactLinkBackupV1 import to.bitkit.models.WalletBackupV1 import to.bitkit.services.LightningService @@ -33,7 +44,7 @@ import kotlin.time.Clock import kotlin.time.ExperimentalTime import kotlin.time.Instant -@OptIn(ExperimentalTime::class) +@OptIn(ExperimentalCoroutinesApi::class, ExperimentalTime::class) class BackupRepoTest : BaseUnitTest() { private val context = mock() private val cacheStore = mock() @@ -51,7 +62,9 @@ class BackupRepoTest : BaseUnitTest() { private val clock = mock() private val db = mock() private val transferDao = mock() + private val cacheData = MutableStateFlow(AppCacheData()) private val settingsData = MutableStateFlow(SettingsData()) + private val widgetsData = MutableStateFlow(WidgetsData()) private lateinit var sut: BackupRepo @@ -62,13 +75,20 @@ class BackupRepoTest : BaseUnitTest() { whenever { transferDao.upsert(any>()) }.thenReturn(Unit) whenever { cacheStore.updateBackupStatus(any(), any()) }.thenReturn(Unit) whenever { cacheStore.update(any()) }.thenReturn(Unit) + whenever(cacheStore.data).thenReturn(cacheData) whenever(settingsStore.data).thenReturn(settingsData) whenever { settingsStore.update(any()) }.thenReturn(Unit) + whenever(widgetsStore.data).thenReturn(widgetsData) whenever { vssBackupClient.getObject(any()) }.thenReturn(Result.success(null)) whenever { vssBackupClient.putObject(any(), any()) } .thenReturn(Result.success(VssItem(key = BackupCategory.SETTINGS.name, value = byteArrayOf(), version = 1))) + whenever { vssBackupClient.setupWithRetry(any(), any(), any()) }.thenReturn(Result.success(Unit)) + whenever { vssBackupClientLdk.setup() }.thenReturn(Result.success(Unit)) + whenever { transferDao.getAll() }.thenReturn(emptyList()) whenever { privatePaykitRepo.restoreBackup(anyOrNull()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitRepo.backupSnapshot() }.thenReturn(Result.success(null)) whenever { privatePaykitAddressReservationRepo.restoreBackup(any()) }.thenReturn(Result.success(Unit)) + whenever { privatePaykitAddressReservationRepo.backupSnapshot() }.thenReturn(Result.success(null)) whenever { privatePaykitAddressReservationRepo.reconcileReservedIndexesWithLdk() }.thenReturn(Result.success(Unit)) @@ -89,6 +109,72 @@ class BackupRepoTest : BaseUnitTest() { verify(settingsStore, never()).update(any()) } + @Test + fun `start observing backs up stale required status after clearing running flag`() = test { + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.WALLET to BackupItemStatus( + running = true, + synced = 1_000, + required = 2_000, + ), + ) + ) + val allowWalletClear = CompletableDeferred() + var delayedWalletClear = false + stubBackupStatuses(backupStatuses, allowWalletClear) { + delayedWalletClear = true + } + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + verify(vssBackupClient, never()).putObject(eq(BackupCategory.WALLET.name), any()) + + allowWalletClear.complete(Unit) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + assertTrue(delayedWalletClear) + verify(vssBackupClient).putObject(eq(BackupCategory.WALLET.name), any()) + } finally { + sut.stopObservingBackups() + } + } + + @Test + fun `failed automatic backup waits for new required timestamp before retrying`() = test { + whenever(clock.now()).thenReturn(Instant.fromEpochMilliseconds(3_000)) + whenever { vssBackupClient.putObject(eq(BackupCategory.SETTINGS.name), any()) } + .thenReturn(Result.failure(BackupRepoTestError("backup failed"))) + val backupStatuses = MutableStateFlow( + mapOf( + BackupCategory.SETTINGS to BackupItemStatus( + synced = 1_000, + required = 2_000, + ), + ) + ) + val allowWalletClear = CompletableDeferred().apply { complete(Unit) } + stubBackupStatuses(backupStatuses, allowWalletClear) {} + stubBackupObservers() + + try { + sut.startObservingBackups() + runCurrent() + advanceTimeBy(5_000) + runCurrent() + advanceTimeBy(5_000) + runCurrent() + + verify(vssBackupClient).putObject(eq(BackupCategory.SETTINGS.name), any()) + } finally { + sut.stopObservingBackups() + } + } + @Test fun `full restore should fail when private Paykit contact links fail to restore`() = test { stubWalletBackup() @@ -134,6 +220,37 @@ class BackupRepoTest : BaseUnitTest() { ) } + private fun stubBackupStatuses( + backupStatuses: MutableStateFlow>, + allowWalletClear: CompletableDeferred, + onWalletClearDelayed: () -> Unit, + ) { + whenever(cacheStore.backupStatuses).thenReturn(backupStatuses) + whenever { cacheStore.updateBackupStatus(any(), any()) }.doSuspendableAnswer { + val category = it.getArgument(0) + val transform = it.getArgument<(BackupItemStatus) -> BackupItemStatus>(1) + if (category == BackupCategory.WALLET && !allowWalletClear.isCompleted) { + onWalletClearDelayed() + allowWalletClear.await() + } + backupStatuses.update { statuses -> + val currentStatus = statuses[category] ?: BackupItemStatus() + statuses + (category to transform(currentStatus)) + } + } + } + + private fun stubBackupObservers() { + whenever { transferDao.observeAll() }.thenReturn(MutableStateFlow(emptyList())) + whenever(blocktankRepo.blocktankState).thenReturn(MutableStateFlow(BlocktankState())) + whenever(activityRepo.activitiesChanged).thenReturn(MutableStateFlow(0L)) + whenever(pubkyRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(privatePaykitRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(privatePaykitAddressReservationRepo.backupStateVersion).thenReturn(MutableStateFlow(0L)) + whenever(preActivityMetadataRepo.preActivityMetadataChanged).thenReturn(MutableStateFlow(0L)) + whenever(lightningService.syncStatusChanged).thenReturn(MutableSharedFlow()) + } + private fun createSut() = BackupRepo( context = context, ioDispatcher = testDispatcher, diff --git a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt index afb09beae3..171064ad53 100644 --- a/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt +++ b/app/src/test/java/to/bitkit/ui/screens/profile/PubkyChoiceViewModelTest.kt @@ -29,6 +29,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { private val packageManager: PackageManager = mock() private val pubkyRepo: PubkyRepo = mock() private val pendingImportContacts = MutableStateFlow>(emptyList()) + private val isAuthenticated = MutableStateFlow(false) private val authCancelEvents = MutableSharedFlow(extraBufferCapacity = 1) private lateinit var sut: PubkyChoiceViewModel @@ -39,6 +40,7 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { whenever(context.getString(R.string.common__error)).thenReturn("Error") whenever(context.getString(R.string.profile__auth_error_title)).thenReturn("Authorization Failed") whenever(pubkyRepo.pendingImportContacts).thenReturn(pendingImportContacts) + whenever(pubkyRepo.isAuthenticated).thenReturn(isAuthenticated) whenever(pubkyRepo.authCancelEvents).thenReturn(authCancelEvents) sut = PubkyChoiceViewModel( context = context, @@ -46,6 +48,19 @@ class PubkyChoiceViewModelTest : BaseUnitTest() { ) } + @Test + fun `session restoration redirects to profile`() = test { + val effects = mutableListOf() + val effectsJob = launch { sut.effects.collect { effects.add(it) } } + + isAuthenticated.value = true + advanceUntilIdle() + + assertEquals(PubkyChoiceEffect.NavigateToProfile, effects.last()) + + effectsJob.cancel() + } + @Test fun `waitForApproval prepareImport failure clears loading and emits no navigation`() = test { whenever(pubkyRepo.completeAuthentication()).thenReturn(Result.success(Unit)) diff --git a/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt b/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt new file mode 100644 index 0000000000..32c6d8d1e7 --- /dev/null +++ b/app/src/test/java/to/bitkit/ui/sheets/WidgetsRouteTest.kt @@ -0,0 +1,19 @@ +package to.bitkit.ui.sheets + +import to.bitkit.models.WidgetType +import kotlin.test.Test +import kotlin.test.assertEquals + +class WidgetsRouteTest { + + @Test + fun `maps widget types to preview routes`() { + assertEquals(WidgetsRoute.BlocksPreview, WidgetType.BLOCK.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.CalculatorPreview, WidgetType.CALCULATOR.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.FactsPreview, WidgetType.FACTS.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.HeadlinesPreview, WidgetType.NEWS.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.PricePreview, WidgetType.PRICE.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.WeatherPreview, WidgetType.WEATHER.toWidgetsPreviewRoute()) + assertEquals(WidgetsRoute.SuggestionsPreview, WidgetType.SUGGESTIONS.toWidgetsPreviewRoute()) + } +} diff --git a/changelog.d/next/972.changed.md b/changelog.d/next/972.changed.md new file mode 100644 index 0000000000..fa8a33b6cc --- /dev/null +++ b/changelog.d/next/972.changed.md @@ -0,0 +1 @@ +The add widget experience now opens as a bottom-sheet flow with in-sheet previews instead of full-screen picker pages. diff --git a/journeys/widgets/add-widgets-flow.xml b/journeys/widgets/add-widgets-flow.xml new file mode 100644 index 0000000000..8a4bf408af --- /dev/null +++ b/journeys/widgets/add-widgets-flow.xml @@ -0,0 +1,14 @@ + + Precondition: onboarded dev wallet, widgets enabled, widgets intro already seen. + + Tap the menu icon + Tap "Widgets" + Verify that the wallet overview widgets section is visible and "Add Widget" is visible + Verify that "Hello, Widgets" is not visible + Tap "Add Widget" + Verify that the "Add Widget" bottom sheet is visible + Scroll the bottom sheet down until "Bitcoin Calculator" is visible + Tap the "Bitcoin Calculator" widget card + Verify that the bottom sheet navigates to "Bitcoin Calculator" and "Save Widget" is visible + + diff --git a/journeys/widgets/widgets-intro.xml b/journeys/widgets/widgets-intro.xml new file mode 100644 index 0000000000..c797ca900f --- /dev/null +++ b/journeys/widgets/widgets-intro.xml @@ -0,0 +1,16 @@ + + Precondition: onboarded dev wallet, widgets enabled, widgets intro unseen. + + Tap the menu icon + Tap "Widgets" + Verify that "Hello, Widgets", "View & Organize", and "Add Widget" are visible + Tap "Add Widget" + Verify that the "Add Widget" bottom sheet is visible with "Bitcoin Price" and "Bitcoin Weather" + Verify that the Widgets intro remains visible behind the sheet backdrop at the top + Tap the "Bitcoin Weather" widget card + Verify that the bottom sheet navigates to "Bitcoin Weather" and "Save Widget" is visible + Tap "Save Widget" + Verify that the wallet overview widgets section is visible + Verify that "Bitcoin Weather" appears as the last widget in the current widget set + +