diff --git a/app/src/main/java/to/bitkit/data/SettingsStore.kt b/app/src/main/java/to/bitkit/data/SettingsStore.kt index 99a39b719..0c60c11ae 100644 --- a/app/src/main/java/to/bitkit/data/SettingsStore.kt +++ b/app/src/main/java/to/bitkit/data/SettingsStore.kt @@ -91,4 +91,6 @@ data class SettingsData( val enableSendAmountWarning: Boolean = false, val backupVerified: Boolean = false, val dismissedSuggestions: List = emptyList(), + val lastTimeAskedBalanceWarningMillis: Long = 0, + val balanceWarningTimes: Int = 0, ) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index 69e3fff3d..b92ab59cc 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -135,6 +135,6 @@ internal object Env { const val BITKIT_TELEGRAM = "https://t.me/bitkitchat" const val BITKIT_GITHUB = "https://github.com/synonymdev" const val BITKIT_HELP_CENTER = "https://help.bitkit.to" - const val TERMS_OF_USE_URL = "https://bitkit.to/terms-of-use" + const val STORING_BITCOINS_URL = "https://en.bitcoin.it/wiki/Storing_bitcoins" } diff --git a/app/src/main/java/to/bitkit/ui/ContentView.kt b/app/src/main/java/to/bitkit/ui/ContentView.kt index fd170cd18..3b01d141d 100644 --- a/app/src/main/java/to/bitkit/ui/ContentView.kt +++ b/app/src/main/java/to/bitkit/ui/ContentView.kt @@ -370,7 +370,6 @@ fun ContentView( BottomSheetType.BackupNavigation -> BackupNavigationSheet( onDismiss = { appViewModel.hideSheet() }, ) - null -> Unit } } 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 792f0b4e8..b4f9a7660 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeScreen.kt @@ -1,6 +1,7 @@ package to.bitkit.ui.screens.wallets import android.Manifest +import android.content.Intent import android.os.Build import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -60,6 +61,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex +import androidx.core.net.toUri import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavController @@ -101,6 +103,7 @@ import to.bitkit.ui.scaffold.AppAlertDialog import to.bitkit.ui.scaffold.AppScaffold import to.bitkit.ui.screens.wallets.activity.AllActivityScreen import to.bitkit.ui.screens.wallets.activity.components.ActivityListSimple +import to.bitkit.ui.screens.wallets.sheets.HighBalanceWarningSheet import to.bitkit.ui.screens.widgets.DragAndDropWidget import to.bitkit.ui.screens.widgets.DragDropColumn import to.bitkit.ui.screens.widgets.blocks.BlockCard @@ -254,7 +257,8 @@ fun HomeScreen( }, onMoveWidget = { fromIndex, toIndex -> homeViewModel.moveWidget(fromIndex, toIndex) - } + }, + onDismissHighBalanceSheet = { homeViewModel.dismissHighBalanceSheet() }, ) } composable( @@ -336,9 +340,6 @@ fun HomeScreen( .systemBarsPadding() ) - - // Drawer overlay and content - moved from AppScaffold to here - // Semi-transparent overlay when drawer is open AnimatedVisibility( visible = drawerState.currentValue == DrawerValue.Open, modifier = Modifier @@ -498,6 +499,7 @@ private fun HomeContentView( walletNavController: NavController, drawerState: DrawerState, onRefresh: () -> Unit, + onDismissHighBalanceSheet: () -> Unit, ) { val scope = rememberCoroutineScope() @@ -789,6 +791,19 @@ private fun HomeContentView( state = pullRefreshState, modifier = Modifier.align(Alignment.TopCenter) ) + + if (homeUiState.highBalanceSheetVisible) { + val context = LocalContext.current + HighBalanceWarningSheet( + onDismiss = onDismissHighBalanceSheet, + understoodClick = onDismissHighBalanceSheet, + learnMoreClick = { + val intent = Intent(Intent.ACTION_VIEW, Env.STORING_BITCOINS_URL.toUri()) + context.startActivity(intent) + onDismissHighBalanceSheet() + } + ) + } } } } @@ -845,6 +860,7 @@ private fun HomeContentViewPreview() { onClickEnableEdit = {}, onClickEditWidget = {}, onClickDeleteWidget = {}, + onDismissHighBalanceSheet = {}, onMoveWidget = { _, _ -> }, ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt index 3ba0aab6b..682b671eb 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeUiState.kt @@ -33,4 +33,5 @@ data class HomeUiState( val currentPrice: PriceDTO? = null, val isEditingWidgets: Boolean = false, val deleteWidgetAlert: WidgetType? = null, + val highBalanceSheetVisible: Boolean = false, ) diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt index 1563f8aca..7e71fffb3 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/HomeViewModel.kt @@ -8,9 +8,11 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.datetime.Clock import to.bitkit.data.SettingsStore import to.bitkit.models.Suggestion import to.bitkit.models.WidgetType @@ -18,9 +20,11 @@ import to.bitkit.models.toSuggestionOrNull import to.bitkit.models.widget.ArticleModel import to.bitkit.models.widget.toArticleModel import to.bitkit.models.widget.toBlockModel +import to.bitkit.repositories.CurrencyRepo import to.bitkit.repositories.WalletRepo import to.bitkit.repositories.WidgetsRepo import to.bitkit.ui.screens.widgets.blocks.toWeatherModel +import java.math.BigDecimal import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -29,6 +33,7 @@ class HomeViewModel @Inject constructor( private val walletRepo: WalletRepo, private val widgetsRepo: WidgetsRepo, private val settingsStore: SettingsStore, + private val currencyRepo: CurrencyRepo ) : ViewModel() { private val _uiState = MutableStateFlow(HomeUiState()) @@ -41,6 +46,7 @@ class HomeViewModel @Inject constructor( setupStateObservation() setupArticleRotation() setupFactRotation() + checkHighBalance() } private fun setupStateObservation() { @@ -124,21 +130,56 @@ class HomeViewModel @Inject constructor( _currentFact.value = null } - fun removeSuggestion(suggestion: Suggestion) { + private fun checkHighBalance() { viewModelScope.launch { - settingsStore.addDismissedSuggestion(suggestion) + delay(CHECK_DELAY_MILLISECONDS) + + val settings = settingsStore.data.first() + + val totalOnChainSats = walletRepo.balanceState.value.totalSats + val balanceUsd = satsToUsd(totalOnChainSats) ?: return@launch + val thresholdReached = balanceUsd > BigDecimal(BALANCE_THRESHOLD_USD) + val isTimeOutOver = settings.lastTimeAskedBalanceWarningMillis - ASK_INTERVAL_MILLIS > ASK_INTERVAL_MILLIS + val belowMaxWarnings = settings.balanceWarningTimes < MAX_WARNINGS + + if (thresholdReached && isTimeOutOver && belowMaxWarnings && !_uiState.value.highBalanceSheetVisible) { + settingsStore.update { + it.copy( + balanceWarningTimes = it.balanceWarningTimes + 1, + lastTimeAskedBalanceWarningMillis = Clock.System.now().toEpochMilliseconds() + ) + } + _uiState.update { it.copy(highBalanceSheetVisible = true) } + } + + if (!thresholdReached) { + settingsStore.update { + it.copy( + balanceWarningTimes = 0, + ) + } + } } } - fun refreshWidgets() { + private fun satsToUsd(sats: ULong): BigDecimal? { + val converted = currencyRepo.convertSatsToFiat(sats = sats.toLong(), currency = "USD") + return converted?.value + } + + fun dismissHighBalanceSheet() { + _uiState.update { it.copy(highBalanceSheetVisible = false) } + } + + fun removeSuggestion(suggestion: Suggestion) { viewModelScope.launch { - widgetsRepo.refreshEnabledWidgets() + settingsStore.addDismissedSuggestion(suggestion) } } - fun refreshSpecificWidget(widgetType: WidgetType) { + fun refreshWidgets() { viewModelScope.launch { - widgetsRepo.refreshWidget(widgetType) + widgetsRepo.refreshEnabledWidgets() } } @@ -240,4 +281,16 @@ class HomeViewModel @Inject constructor( val dismissedList = settings.dismissedSuggestions.mapNotNull { it.toSuggestionOrNull() } baseSuggestions.filterNot { it in dismissedList } } + + companion object { + /**How high the balance must be to show this warning to the user (in USD)*/ + private const val BALANCE_THRESHOLD_USD = 500L + private const val MAX_WARNINGS = 3 + + /** 1 day - how long this prompt will be hidden if user taps Later*/ + private const val ASK_INTERVAL_MILLIS = 1000 * 60 * 60 * 24 + + /**How long user needs to stay on the home screen before he will see this prompt*/ + private const val CHECK_DELAY_MILLISECONDS = 2500L + } } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/sheets/HighBalanceWarningSheet.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/sheets/HighBalanceWarningSheet.kt new file mode 100644 index 000000000..e012326f4 --- /dev/null +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/sheets/HighBalanceWarningSheet.kt @@ -0,0 +1,162 @@ +package to.bitkit.ui.screens.wallets.sheets + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import to.bitkit.R +import to.bitkit.ui.components.BodyM +import to.bitkit.ui.components.Display +import to.bitkit.ui.components.ModalBottomSheetHandle +import to.bitkit.ui.components.PrimaryButton +import to.bitkit.ui.components.SecondaryButton +import to.bitkit.ui.components.VerticalSpacer +import to.bitkit.ui.scaffold.ScreenColumn +import to.bitkit.ui.scaffold.SheetTopBar +import to.bitkit.ui.shared.util.gradientBackground +import to.bitkit.ui.theme.AppShapes +import to.bitkit.ui.theme.AppThemeSurface +import to.bitkit.ui.theme.Colors +import to.bitkit.ui.theme.InterFontFamily +import to.bitkit.ui.theme.ModalSheetTopPadding +import to.bitkit.ui.utils.withAccent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HighBalanceWarningSheet( + onDismiss: () -> Unit, + understoodClick: () -> Unit, + learnMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val sheetState: SheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = sheetState, + shape = AppShapes.sheet, + containerColor = Colors.Black, + dragHandle = { ModalBottomSheetHandle() }, + modifier = Modifier + .fillMaxSize() + .padding(top = ModalSheetTopPadding) + ) { + HighBalanceWarningContent( + understoodClick = understoodClick, + learnMoreClick = learnMoreClick, + modifier = modifier + ) + } +} + +@Composable +fun HighBalanceWarningContent( + understoodClick: () -> Unit, + learnMoreClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier + .fillMaxWidth() + .gradientBackground() + .testTag("high_balance_intro_screen") + ) { + SheetTopBar(stringResource(R.string.other__high_balance__nav_title)) + + Column( + modifier = Modifier.padding(horizontal = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Image( + painter = painterResource(R.drawable.exclamation_mark), + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .testTag("high_balance_image") + ) + + Display( + text = stringResource(R.string.other__high_balance__title).withAccent(accentColor = Colors.Yellow), + color = Colors.White, + modifier = Modifier + .fillMaxWidth() + .testTag("high_balance_title") + ) + VerticalSpacer(8.dp) + BodyM( + text = + stringResource(R.string.other__high_balance__text).withAccent( + defaultColor = Colors.White64, + accentStyle = SpanStyle( + fontWeight = FontWeight.Bold, + fontSize = 17.sp, + letterSpacing = 0.4.sp, + fontFamily = InterFontFamily, + color = Colors.White, + ) + ), + color = Colors.White64, + modifier = Modifier + .testTag("high_balance_description") + ) + VerticalSpacer(32.dp) + Row( + modifier = Modifier + .fillMaxWidth() + .testTag("buttons_row"), + horizontalArrangement = Arrangement.spacedBy(16.dp) + ) { + SecondaryButton( + text = stringResource(R.string.other__high_balance__cancel), + fullWidth = false, + onClick = learnMoreClick, + modifier = Modifier + .weight(1f) + .testTag("learn_more_button"), + ) + + PrimaryButton( + text = stringResource(R.string.other__high_balance__continue), + fullWidth = false, + onClick = understoodClick, + modifier = Modifier + .weight(1f) + .testTag("understood_button"), + ) + } + VerticalSpacer(16.dp) + } + } +} + + +@OptIn(ExperimentalMaterial3Api::class) +@Preview(showBackground = true) +@Composable +private fun Preview() { + AppThemeSurface { + HighBalanceWarningContent( + understoodClick = {}, + learnMoreClick = {}, + ) + } +}