diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4c502a7a9..f2aee67b7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -213,6 +213,8 @@ dependencies { implementation(libs.constraintlayout) implementation(libs.androidx.material.icons.extended) + implementation(libs.androidx.emoji2.emojipicker) + implementation("com.google.guava:guava:33.0.0-android") implementation(libs.navigation.material) diff --git a/app/src/main/kotlin/net/primal/android/auth/create/CreateAccountViewModel.kt b/app/src/main/kotlin/net/primal/android/auth/create/CreateAccountViewModel.kt index 8598fe7f6..4528e418e 100644 --- a/app/src/main/kotlin/net/primal/android/auth/create/CreateAccountViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/auth/create/CreateAccountViewModel.kt @@ -12,12 +12,14 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.primal.android.auth.AuthRepository import net.primal.android.auth.create.CreateAccountContract.SideEffect import net.primal.android.auth.create.CreateAccountContract.UiEvent import net.primal.android.auth.create.CreateAccountContract.UiState import net.primal.android.auth.create.api.RecommendedFollowsApi import net.primal.android.auth.create.ui.RecommendedFollow +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.core.files.error.UnsuccessfulFileUpload import net.primal.android.core.serialization.json.NostrJson import net.primal.android.networking.relays.errors.NostrPublishException @@ -27,9 +29,11 @@ import net.primal.android.profile.repository.ProfileRepository import net.primal.android.settings.repository.SettingsRepository import net.primal.android.user.accounts.BOOTSTRAP_RELAYS import net.primal.android.user.repository.UserRepository +import timber.log.Timber @HiltViewModel class CreateAccountViewModel @Inject constructor( + private val dispatcherProvider: CoroutineDispatcherProvider, private val authRepository: AuthRepository, private val settingsRepository: SettingsRepository, private val profileRepository: ProfileRepository, @@ -165,19 +169,23 @@ class CreateAccountViewModel @Inject constructor( try { setState { copy(loading = true) } val userId = state.value.userId!! - profileRepository.setContactsAndRelays( - userId = userId, - contacts = state.value.recommendedFollows - .filter { it.isCurrentUserFollowing } - .map { it.pubkey }.toSet(), - relays = BOOTSTRAP_RELAYS, - ) - settingsRepository.fetchAndPersistAppSettings(userId = userId) + withContext(dispatcherProvider.io()) { + profileRepository.setContactsAndRelays( + userId = userId, + contacts = state.value.recommendedFollows + .filter { it.isCurrentUserFollowing } + .map { it.pubkey }.toSet(), + relays = BOOTSTRAP_RELAYS, + ) + settingsRepository.fetchAndPersistAppSettings(userId = userId) + } setEffect(SideEffect.AccountCreatedAndPersisted(pubkey = userId)) - } catch (e: NostrPublishException) { - setState { copy(error = UiState.CreateError.FailedToFollow(e)) } - } catch (e: WssException) { - setState { copy(error = UiState.CreateError.FailedToFollow(e)) } + } catch (error: NostrPublishException) { + Timber.e(error) + setState { copy(error = UiState.CreateError.FailedToFollow(error)) } + } catch (error: WssException) { + Timber.e(error) + setState { copy(error = UiState.CreateError.FailedToFollow(error)) } } finally { setState { copy(loading = false) } } diff --git a/app/src/main/kotlin/net/primal/android/auth/login/LoginViewModel.kt b/app/src/main/kotlin/net/primal/android/auth/login/LoginViewModel.kt index b86f07317..68deb8203 100644 --- a/app/src/main/kotlin/net/primal/android/auth/login/LoginViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/auth/login/LoginViewModel.kt @@ -13,17 +13,21 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import net.primal.android.auth.AuthRepository import net.primal.android.auth.login.LoginContract.SideEffect import net.primal.android.auth.login.LoginContract.UiEvent import net.primal.android.auth.login.LoginContract.UiState +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.networking.sockets.errors.WssException import net.primal.android.settings.muted.repository.MutedUserRepository import net.primal.android.settings.repository.SettingsRepository import net.primal.android.user.repository.UserRepository +import timber.log.Timber @HiltViewModel class LoginViewModel @Inject constructor( + private val dispatcherProvider: CoroutineDispatcherProvider, private val settingsRepository: SettingsRepository, private val authRepository: AuthRepository, private val userRepository: UserRepository, @@ -66,11 +70,14 @@ class LoginViewModel @Inject constructor( setState { copy(loading = true) } try { val pubkey = authRepository.login(nostrKey = nostrKey) - userRepository.fetchAndUpdateUserAccount(userId = pubkey) - settingsRepository.fetchAndPersistAppSettings(userId = pubkey) - mutedUserRepository.fetchAndPersistMuteList(userId = pubkey) + withContext(dispatcherProvider.io()) { + userRepository.fetchAndUpdateUserAccount(userId = pubkey) + settingsRepository.fetchAndPersistAppSettings(userId = pubkey) + mutedUserRepository.fetchAndPersistMuteList(userId = pubkey) + } setEffect(SideEffect.LoginSuccess(pubkey = pubkey)) } catch (error: WssException) { + Timber.e(error) setErrorState(error = UiState.LoginError.GenericError(error)) } finally { setState { copy(loading = false) } diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/list/FeedLazyColumn.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/list/FeedLazyColumn.kt index 52ab8480c..6be17f7fd 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/list/FeedLazyColumn.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/list/FeedLazyColumn.kt @@ -92,7 +92,7 @@ fun FeedLazyColumn( zappingState = zappingState, onZap = { zapAmount, zapDescription -> if (zappingState.canZap(zapAmount)) { - onZapClick(post, zapAmount, zapDescription) + onZapClick(post, zapAmount.toULong(), zapDescription) } else { showCantZapWarning = true } diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/model/ZappingState.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/model/ZappingState.kt index dea790d6d..27922baa5 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/model/ZappingState.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/model/ZappingState.kt @@ -1,11 +1,15 @@ package net.primal.android.core.compose.feed.model +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.ContentZapDefault +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_CONFIG +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_DEFAULT import net.primal.android.user.domain.WalletPreference data class ZappingState( val walletConnected: Boolean = false, val walletPreference: WalletPreference = WalletPreference.Undefined, val walletBalanceInBtc: String? = null, - val defaultZapAmount: ULong = 42.toULong(), - val zapOptions: List = emptyList(), + val zapDefault: ContentZapDefault = DEFAULT_ZAP_DEFAULT, + val zapsConfig: List = DEFAULT_ZAP_CONFIG, ) diff --git a/app/src/main/kotlin/net/primal/android/core/compose/feed/zaps/ZapBottomSheet.kt b/app/src/main/kotlin/net/primal/android/core/compose/feed/zaps/ZapBottomSheet.kt index bcbb96197..50e2c562f 100644 --- a/app/src/main/kotlin/net/primal/android/core/compose/feed/zaps/ZapBottomSheet.kt +++ b/app/src/main/kotlin/net/primal/android/core/compose/feed/zaps/ZapBottomSheet.kt @@ -14,12 +14,12 @@ 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.requiredHeight -import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField @@ -27,49 +27,59 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableLongStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.vectorResource import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.withStyle -import androidx.compose.ui.unit.TextUnit -import androidx.compose.ui.unit.TextUnitType import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.text.isDigitsOnly import net.primal.android.R import net.primal.android.core.compose.AdjustTemporarilySystemBarColors import net.primal.android.core.compose.PrimalDefaults import net.primal.android.core.compose.button.PrimalLoadingButton import net.primal.android.core.compose.feed.model.ZappingState +import net.primal.android.core.compose.foundation.keyboardVisibilityAsState import net.primal.android.core.utils.shortened -import net.primal.android.settings.zaps.DEFAULT_ZAP_OPTIONS +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_CONFIG import net.primal.android.settings.zaps.PRESETS_COUNT import net.primal.android.theme.AppTheme -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @Composable fun ZapBottomSheet( receiverName: String, zappingState: ZappingState, onDismissRequest: () -> Unit, - onZap: (ULong, String?) -> Unit, + onZap: (Long, String?) -> Unit, ) { - val zapOptionPairs = zappingState.extractOptionPairs() + val zapConfig: List = zappingState.ensureZapConfig() - var selectedZapAmount by remember { mutableStateOf(zappingState.defaultZapAmount) } - var selectedZapComment by remember { mutableStateOf("") } + var customZapAmount by remember { mutableStateOf("") } + var selectedZapComment by remember { mutableStateOf(zapConfig.first().message) } + var selectedZapAmount by remember { mutableLongStateOf(zapConfig.first().amount) } val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val keyboardController = LocalSoftwareKeyboardController.current + val keyboardVisible by keyboardVisibilityAsState() + AdjustTemporarilySystemBarColors( navigationBarColor = AppTheme.extraColorScheme.surfaceVariantAlt2, ) @@ -86,76 +96,115 @@ fun ZapBottomSheet( .fillMaxWidth() .padding(bottom = 8.dp), ) { - ZapTitle(receiverName = receiverName, amount = selectedZapAmount) + ZapTitle( + modifier = Modifier.padding(horizontal = 16.dp), + receiverName = receiverName, + amount = selectedZapAmount, + ) ZapOptions( - zapOptionPairs = zapOptionPairs, + zapConfig = zapConfig, selectedZapAmount = selectedZapAmount, onSelectedZapAmountChange = { amount -> + keyboardController?.hide() selectedZapAmount = amount - selectedZapComment = zapOptionPairs.find { - it.first == amount - }?.second ?: selectedZapComment + selectedZapComment = zapConfig.find { + it.amount == amount + }?.message ?: selectedZapComment }, ) OutlinedTextField( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 24.dp) - .requiredHeight(height = 54.dp), + .padding(horizontal = 24.dp), singleLine = true, colors = PrimalDefaults.outlinedTextFieldColors( unfocusedBorderColor = Color.Transparent, focusedBorderColor = Color.Transparent, ), shape = RoundedCornerShape(8.dp), - value = selectedZapComment, - onValueChange = { selectedZapComment = it }, - textStyle = AppTheme.typography.bodySmall, - + value = customZapAmount, + onValueChange = { + when { + it.isEmpty() -> customZapAmount = "" + it.isDigitsOnly() && it.length <= 8 && it.toLong() > 0 -> customZapAmount = it + } + selectedZapAmount = customZapAmount.toLongOrNull() ?: zapConfig.first().amount + }, + textStyle = AppTheme.typography.bodyMedium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), placeholder = { Text( modifier = Modifier.fillMaxWidth(), - text = stringResource(id = R.string.zap_bottom_sheet_comment_placeholder), + text = stringResource(id = R.string.zap_bottom_sheet_custom_amount_placeholder), textAlign = TextAlign.Left, - style = AppTheme.typography.bodySmall, + style = AppTheme.typography.bodyMedium, color = AppTheme.extraColorScheme.onSurfaceVariantAlt4, ) }, ) - Spacer(modifier = Modifier.height(24.dp)) - PrimalLoadingButton( + Spacer(modifier = Modifier.height(16.dp)) + OutlinedTextField( modifier = Modifier .fillMaxWidth() - .height(56.dp) .padding(horizontal = 24.dp), - text = stringResource(id = R.string.zap_bottom_sheet_zap_button), - leadingIcon = ImageVector.vectorResource(id = R.drawable.zap), - onClick = { - onDismissRequest() - onZap(selectedZapAmount, selectedZapComment) + singleLine = true, + colors = PrimalDefaults.outlinedTextFieldColors( + unfocusedBorderColor = Color.Transparent, + focusedBorderColor = Color.Transparent, + ), + shape = RoundedCornerShape(8.dp), + value = selectedZapComment, + onValueChange = { selectedZapComment = it }, + textStyle = AppTheme.typography.bodyMedium, + placeholder = { + Text( + modifier = Modifier.fillMaxWidth(), + text = stringResource(id = R.string.zap_bottom_sheet_comment_placeholder), + textAlign = TextAlign.Left, + style = AppTheme.typography.bodyMedium, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt4, + ) }, ) + Spacer(modifier = Modifier.height(16.dp)) + if (!keyboardVisible) { + PrimalLoadingButton( + modifier = Modifier + .fillMaxWidth() + .height(56.dp) + .padding(horizontal = 24.dp), + text = stringResource(id = R.string.zap_bottom_sheet_zap_button), + leadingIcon = ImageVector.vectorResource(id = R.drawable.zap), + onClick = { + onDismissRequest() + onZap(selectedZapAmount, selectedZapComment) + }, + ) + } } } } @Composable private fun ZapOptions( - zapOptionPairs: List>, - selectedZapAmount: ULong, - onSelectedZapAmountChange: (ULong) -> Unit, + zapConfig: List, + selectedZapAmount: Long, + onSelectedZapAmountChange: (Long) -> Unit, ) { LazyVerticalGrid( columns = GridCells.Fixed(3), contentPadding = PaddingValues(12.dp), ) { - items(zapOptionPairs) { (defaultAmount, defaultComment) -> + items(zapConfig) { ZapOption( - defaultAmount = defaultAmount, - defaultComment = defaultComment, - selected = selectedZapAmount == defaultAmount, + defaultAmount = it.amount, + defaultEmoji = it.emoji, + selected = selectedZapAmount == it.amount, onClick = { - onSelectedZapAmountChange(defaultAmount) + onSelectedZapAmountChange(it.amount) }, ) } @@ -164,75 +213,58 @@ private fun ZapOptions( @Composable private fun ZapOption( - defaultAmount: ULong, - defaultComment: String, + defaultAmount: Long, + defaultEmoji: String, selected: Boolean, onClick: () -> Unit, ) { - val selectedBorderGradientColors = Brush.linearGradient( - listOf( - AppTheme.colorScheme.primary, - AppTheme.colorScheme.primary, - ), - ) - - val backgroundColor = - if (selected) AppTheme.colorScheme.surface else AppTheme.extraColorScheme.surfaceVariantAlt1 + val backgroundColor = if (selected) AppTheme.colorScheme.surface else AppTheme.extraColorScheme.surfaceVariantAlt1 val borderWidth = if (selected) 1.dp else 0.dp - val borderBrush = if (selected) { - selectedBorderGradientColors - } else { - Brush.linearGradient( - listOf( - Color.Transparent, - Color.Transparent, - ), - ) - } + val borderColor = if (selected) AppTheme.colorScheme.tertiary else Color.Transparent Box( modifier = Modifier .padding(all = 12.dp) .clip(AppTheme.shapes.small) - .border( - width = borderWidth, - shape = AppTheme.shapes.small, - brush = borderBrush, - ) - .background( - color = backgroundColor, - ) + .border(width = borderWidth, shape = AppTheme.shapes.small, color = borderColor) + .background(color = backgroundColor) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ) - .requiredHeight(88.dp) - .requiredWidth(88.dp) + .requiredSize(88.dp) .aspectRatio(1f), ) { Column( - modifier = Modifier - .fillMaxSize(), + modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { Text( - text = defaultComment, - fontWeight = FontWeight.W600, - fontSize = TextUnit( - value = 20f, - type = TextUnitType.Sp, - ), + modifier = Modifier.padding(bottom = 8.dp), + text = defaultEmoji, + fontWeight = FontWeight.Black, + fontSize = 28.sp, + ) + Text( + text = defaultAmount.shortened(), + fontWeight = FontWeight.SemiBold, + fontSize = 20.sp, + color = AppTheme.colorScheme.onPrimary, ) - Text(text = defaultAmount.shortened()) } } } @Composable -private fun ZapTitle(receiverName: String, amount: ULong) { +private fun ZapTitle( + modifier: Modifier = Modifier, + receiverName: String, + amount: Long, +) { Box( + modifier = modifier, contentAlignment = Alignment.Center, ) { Text( @@ -240,11 +272,8 @@ private fun ZapTitle(receiverName: String, amount: ULong) { withStyle( style = SpanStyle( color = AppTheme.extraColorScheme.onSurfaceVariantAlt1, - fontWeight = FontWeight.W700, - fontSize = TextUnit( - value = 20f, - type = TextUnitType.Sp, - ), + fontWeight = FontWeight.Bold, + fontSize = 20.sp, ), ) { append("ZAP ${receiverName.uppercase()} ") @@ -252,11 +281,8 @@ private fun ZapTitle(receiverName: String, amount: ULong) { withStyle( style = SpanStyle( color = AppTheme.colorScheme.onSurfaceVariant, - fontWeight = FontWeight.W900, - fontSize = TextUnit( - value = 20f, - type = TextUnitType.Sp, - ), + fontWeight = FontWeight.Black, + fontSize = 20.sp, ), ) { append("${amount.shortened()} ") @@ -264,11 +290,8 @@ private fun ZapTitle(receiverName: String, amount: ULong) { withStyle( style = SpanStyle( color = AppTheme.extraColorScheme.onSurfaceVariantAlt1, - fontWeight = FontWeight.W700, - fontSize = TextUnit( - value = 14f, - type = TextUnitType.Sp, - ), + fontWeight = FontWeight.Bold, + fontSize = 14.sp, ), ) { append("SATS") @@ -278,21 +301,10 @@ private fun ZapTitle(receiverName: String, amount: ULong) { } } -private fun ZappingState.extractOptionPairs(): List> { - return if (this.zapOptions.size == PRESETS_COUNT) { - this.zapOptions +private fun ZappingState.ensureZapConfig(): List { + return if (this.zapsConfig.size == PRESETS_COUNT) { + this.zapsConfig } else { - DEFAULT_ZAP_OPTIONS - }.toUiPairs() -} - -private fun List.toUiPairs(): List> { - return listOf( - Pair(this[0], "πŸ‘"), - Pair(this[1], "🌿"), - Pair(this[2], "πŸ€™"), - Pair(this[3], "πŸ’œ"), - Pair(this[4], "πŸ”₯"), - Pair(this[5], "πŸš€"), - ) + DEFAULT_ZAP_CONFIG + } } diff --git a/app/src/main/kotlin/net/primal/android/discuss/feed/FeedViewModel.kt b/app/src/main/kotlin/net/primal/android/discuss/feed/FeedViewModel.kt index 8ad866639..3790f93a8 100644 --- a/app/src/main/kotlin/net/primal/android/discuss/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/discuss/feed/FeedViewModel.kt @@ -22,6 +22,7 @@ import kotlinx.coroutines.withContext import net.primal.android.config.dynamic.AppConfigUpdater import net.primal.android.core.compose.feed.model.FeedPostsSyncStats import net.primal.android.core.compose.feed.model.asFeedPostUi +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.core.utils.ellipsizeMiddle import net.primal.android.discuss.feed.FeedContract.UiEvent import net.primal.android.discuss.feed.FeedContract.UiState @@ -47,6 +48,7 @@ import net.primal.android.wallet.zaps.hasWallet @HiltViewModel class FeedViewModel @Inject constructor( savedStateHandle: SavedStateHandle, + private val dispatcherProvider: CoroutineDispatcherProvider, private val feedRepository: FeedRepository, private val postRepository: PostRepository, private val activeAccountStore: ActiveAccountStore, @@ -136,8 +138,8 @@ class FeedViewModel @Inject constructor( zappingState = this.zappingState.copy( walletConnected = it.hasWallet(), walletPreference = it.walletPreference, - defaultZapAmount = it.appSettings?.defaultZapAmount ?: this.zappingState.defaultZapAmount, - zapOptions = it.appSettings?.zapOptions ?: this.zappingState.zapOptions, + zapDefault = it.appSettings?.zapDefault ?: this.zappingState.zapDefault, + zapsConfig = it.appSettings?.zapsConfig ?: this.zappingState.zapsConfig, walletBalanceInBtc = it.primalWalletBalanceInBtc, ), ) @@ -181,8 +183,10 @@ class FeedViewModel @Inject constructor( private fun updateUserData() = viewModelScope.launch { - userDataUpdater?.updateUserDataWithDebounce(30.minutes) - appConfigUpdater.updateAppConfigWithDebounce(30.minutes) + withContext(dispatcherProvider.io()) { + userDataUpdater?.updateUserDataWithDebounce(30.minutes) + appConfigUpdater.updateAppConfigWithDebounce(30.minutes) + } } private fun likePost(postLikeAction: UiEvent.PostLikeAction) = diff --git a/app/src/main/kotlin/net/primal/android/discuss/list/FeedListViewModel.kt b/app/src/main/kotlin/net/primal/android/discuss/list/FeedListViewModel.kt index c9c2f9370..7172003df 100644 --- a/app/src/main/kotlin/net/primal/android/discuss/list/FeedListViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/discuss/list/FeedListViewModel.kt @@ -8,15 +8,20 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.discuss.list.FeedListContract.UiState import net.primal.android.discuss.list.model.FeedUi import net.primal.android.feed.db.Feed import net.primal.android.feed.repository.FeedRepository +import net.primal.android.networking.sockets.errors.WssException import net.primal.android.settings.repository.SettingsRepository import net.primal.android.user.accounts.active.ActiveAccountStore +import timber.log.Timber @HiltViewModel class FeedListViewModel @Inject constructor( + private val dispatcherProvider: CoroutineDispatcherProvider, private val feedRepository: FeedRepository, private val settingsRepository: SettingsRepository, private val activeAccountStore: ActiveAccountStore, @@ -44,9 +49,13 @@ class FeedListViewModel @Inject constructor( private fun fetchLatestFeeds() = viewModelScope.launch { - settingsRepository.fetchAndPersistAppSettings( - userId = activeAccountStore.activeUserId(), - ) + try { + withContext(dispatcherProvider.io()) { + settingsRepository.fetchAndPersistAppSettings(userId = activeAccountStore.activeUserId()) + } + } catch (error: WssException) { + Timber.e(error) + } } private fun Feed.asFeedUi() = FeedUi(directive = this.directive, name = this.name) diff --git a/app/src/main/kotlin/net/primal/android/explore/feed/ExploreFeedViewModel.kt b/app/src/main/kotlin/net/primal/android/explore/feed/ExploreFeedViewModel.kt index c52f3e25b..5606f7df7 100644 --- a/app/src/main/kotlin/net/primal/android/explore/feed/ExploreFeedViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/explore/feed/ExploreFeedViewModel.kt @@ -111,9 +111,8 @@ class ExploreFeedViewModel @Inject constructor( zappingState = this.zappingState.copy( walletConnected = it.data.hasWallet(), walletPreference = it.data.walletPreference, - defaultZapAmount = it.data.appSettings?.defaultZapAmount - ?: this.zappingState.defaultZapAmount, - zapOptions = it.data.appSettings?.zapOptions ?: this.zappingState.zapOptions, + zapDefault = it.data.appSettings?.zapDefault ?: this.zappingState.zapDefault, + zapsConfig = it.data.appSettings?.zapsConfig ?: this.zappingState.zapsConfig, walletBalanceInBtc = it.data.primalWalletBalanceInBtc, ), ) diff --git a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentAppSettings.kt b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentAppSettings.kt index b8d153697..108f9cd3d 100644 --- a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentAppSettings.kt +++ b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentAppSettings.kt @@ -1,5 +1,6 @@ package net.primal.android.nostr.model.primal.content +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonObject @@ -8,6 +9,10 @@ data class ContentAppSettings( val description: String? = null, val feeds: List = emptyList(), val notifications: JsonObject, + @Deprecated("Replaced with zapDefault.") val defaultZapAmount: ULong? = null, + @Deprecated("Replaced with zapsConfig.") val zapOptions: List = emptyList(), + val zapDefault: ContentZapDefault? = null, + @SerialName("zapConfig") val zapsConfig: List = emptyList(), ) diff --git a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapConfigItem.kt b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapConfigItem.kt new file mode 100644 index 000000000..a514d5cf5 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapConfigItem.kt @@ -0,0 +1,19 @@ +package net.primal.android.nostr.model.primal.content + +import kotlinx.serialization.Serializable + +@Serializable +data class ContentZapConfigItem( + val emoji: String, + val amount: Long, + val message: String, +) + +val DEFAULT_ZAP_CONFIG = listOf( + ContentZapConfigItem(emoji = "πŸ‘", amount = 21, message = "Great post πŸ‘"), + ContentZapConfigItem(emoji = "πŸš€", amount = 420, message = "Let's go πŸš€"), + ContentZapConfigItem(emoji = "β˜•", amount = 1000, message = "Coffie on me β˜•"), + ContentZapConfigItem(emoji = "🍻", amount = 5000, message = "Cheers 🍻"), + ContentZapConfigItem(emoji = "🍷", amount = 10000, message = "Party time 🍷"), + ContentZapConfigItem(emoji = "πŸ‘‘", amount = 100000, message = "Generational wealth πŸ‘‘"), +) diff --git a/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapDefault.kt b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapDefault.kt new file mode 100644 index 000000000..a375cf0b1 --- /dev/null +++ b/app/src/main/kotlin/net/primal/android/nostr/model/primal/content/ContentZapDefault.kt @@ -0,0 +1,11 @@ +package net.primal.android.nostr.model.primal.content + +import kotlinx.serialization.Serializable + +@Serializable +data class ContentZapDefault( + val amount: Long, + val message: String, +) + +val DEFAULT_ZAP_DEFAULT = ContentZapDefault(amount = 42L, message = "Onward \uD83E\uDEE1") diff --git a/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsScreen.kt b/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsScreen.kt index 1a879f6cd..f226e6c87 100644 --- a/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsScreen.kt @@ -288,7 +288,7 @@ private fun NotificationsList( zappingState = state.zappingState, onZap = { zapAmount, zapDescription -> if (state.zappingState.canZap(zapAmount)) { - onZapClick(post, zapAmount, zapDescription) + onZapClick(post, zapAmount.toULong(), zapDescription) } else { showCantZapWarning = true } diff --git a/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsViewModel.kt b/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsViewModel.kt index d40211878..cc1e9500d 100644 --- a/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/notifications/list/NotificationsViewModel.kt @@ -101,8 +101,8 @@ class NotificationsViewModel @Inject constructor( zappingState = this.zappingState.copy( walletConnected = it.hasWallet(), walletPreference = it.walletPreference, - defaultZapAmount = it.appSettings?.defaultZapAmount ?: this.zappingState.defaultZapAmount, - zapOptions = it.appSettings?.zapOptions ?: this.zappingState.zapOptions, + zapDefault = it.appSettings?.zapDefault ?: this.zappingState.zapDefault, + zapsConfig = it.appSettings?.zapsConfig ?: this.zappingState.zapsConfig, walletBalanceInBtc = it.primalWalletBalanceInBtc, ), ) diff --git a/app/src/main/kotlin/net/primal/android/profile/details/ProfileViewModel.kt b/app/src/main/kotlin/net/primal/android/profile/details/ProfileViewModel.kt index 7e6fe9423..ab504f4e9 100644 --- a/app/src/main/kotlin/net/primal/android/profile/details/ProfileViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/profile/details/ProfileViewModel.kt @@ -115,8 +115,8 @@ class ProfileViewModel @Inject constructor( zappingState = this.zappingState.copy( walletConnected = it.hasWallet(), walletPreference = it.walletPreference, - defaultZapAmount = it.appSettings?.defaultZapAmount ?: this.zappingState.defaultZapAmount, - zapOptions = it.appSettings?.zapOptions ?: this.zappingState.zapOptions, + zapDefault = it.appSettings?.zapDefault ?: this.zappingState.zapDefault, + zapsConfig = it.appSettings?.zapsConfig ?: this.zappingState.zapsConfig, walletBalanceInBtc = it.primalWalletBalanceInBtc, ), ) diff --git a/app/src/main/kotlin/net/primal/android/settings/notifications/NotificationsSettingsViewModel.kt b/app/src/main/kotlin/net/primal/android/settings/notifications/NotificationsSettingsViewModel.kt index f262871a5..79eb5716a 100644 --- a/app/src/main/kotlin/net/primal/android/settings/notifications/NotificationsSettingsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/settings/notifications/NotificationsSettingsViewModel.kt @@ -14,10 +14,12 @@ import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.jsonPrimitive +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.networking.sockets.errors.WssException import net.primal.android.notifications.domain.NotificationType import net.primal.android.settings.notifications.NotificationsSettingsContract.UiEvent.NotificationSettingChanged @@ -25,11 +27,13 @@ import net.primal.android.settings.notifications.NotificationsSettingsContract.U import net.primal.android.settings.notifications.NotificationsSettingsContract.UiState.ApiError.UpdateAppSettingsError import net.primal.android.settings.repository.SettingsRepository import net.primal.android.user.accounts.active.ActiveAccountStore +import timber.log.Timber @HiltViewModel class NotificationsSettingsViewModel @Inject constructor( - val settingsRepository: SettingsRepository, - val activeAccountStore: ActiveAccountStore, + private val dispatcherProvider: CoroutineDispatcherProvider, + private val settingsRepository: SettingsRepository, + private val activeAccountStore: ActiveAccountStore, ) : ViewModel() { private val _state = MutableStateFlow(NotificationsSettingsContract.UiState()) val state = _state.asStateFlow() @@ -111,10 +115,11 @@ class NotificationsSettingsViewModel @Inject constructor( private fun fetchLatestAppSettings() = viewModelScope.launch { try { - settingsRepository.fetchAndPersistAppSettings( - userId = activeAccountStore.activeUserId(), - ) + withContext(dispatcherProvider.io()) { + settingsRepository.fetchAndPersistAppSettings(userId = activeAccountStore.activeUserId()) + } } catch (error: WssException) { + Timber.e(error) setState { copy(error = FetchAppSettingsError(cause = error)) } } } diff --git a/app/src/main/kotlin/net/primal/android/settings/repository/SettingsRepository.kt b/app/src/main/kotlin/net/primal/android/settings/repository/SettingsRepository.kt index 90b10232f..604d9ff7e 100644 --- a/app/src/main/kotlin/net/primal/android/settings/repository/SettingsRepository.kt +++ b/app/src/main/kotlin/net/primal/android/settings/repository/SettingsRepository.kt @@ -2,8 +2,6 @@ package net.primal.android.settings.repository import androidx.room.withTransaction import javax.inject.Inject -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext import kotlinx.serialization.json.JsonObject import net.primal.android.core.serialization.json.NostrJson import net.primal.android.core.serialization.json.decodeFromStringOrNull @@ -11,6 +9,10 @@ import net.primal.android.db.PrimalDatabase import net.primal.android.feed.db.Feed import net.primal.android.nostr.model.primal.content.ContentAppSettings import net.primal.android.nostr.model.primal.content.ContentFeedData +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.ContentZapDefault +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_CONFIG +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_DEFAULT import net.primal.android.settings.api.SettingsApi import net.primal.android.user.accounts.UserAccountsStore import net.primal.android.user.domain.UserAccount @@ -20,21 +22,28 @@ class SettingsRepository @Inject constructor( private val database: PrimalDatabase, private val accountsStore: UserAccountsStore, ) { - suspend fun fetchAndPersistAppSettings(userId: String) = - withContext(Dispatchers.IO) { - val appSettings = fetchAppSettings(userId = userId) ?: return@withContext - persistAppSettings(userId = userId, appSettings = appSettings) - } + suspend fun fetchAndPersistAppSettings(userId: String) { + val appSettings = fetchAppSettings(userId = userId) ?: return + persistAppSettings(userId = userId, appSettings = appSettings) + } - suspend fun updateAndPersistDefaultZapAmount(userId: String, defaultAmount: ULong) { + suspend fun updateAndPersistZapDefault(userId: String, zapDefault: ContentZapDefault) { updateAndPersistAppSettings(userId = userId) { - copy(defaultZapAmount = defaultAmount) + copy(zapDefault = zapDefault) } } - suspend fun updateAndPersistZapOptions(userId: String, zapOptions: List) { + suspend fun updateAndPersistZapPresetsConfig( + userId: String, + presetIndex: Int, + zapPreset: ContentZapConfigItem, + ) { updateAndPersistAppSettings(userId = userId) { - copy(zapOptions = zapOptions) + copy( + zapsConfig = this.zapsConfig.toMutableList().apply { + this[presetIndex] = zapPreset + }, + ) } } @@ -68,7 +77,7 @@ class SettingsRepository @Inject constructor( } } - suspend fun updateAndPersistFeeds(userId: String, feeds: List) { + private suspend fun updateAndPersistFeeds(userId: String, feeds: List) { updateAndPersistAppSettings(userId = userId) { copy(feeds = feeds) } @@ -79,6 +88,38 @@ class SettingsRepository @Inject constructor( updateAndPersistFeeds(userId = userId, feeds = remoteDefaultAppSettings.feeds) } + suspend fun ensureZapConfig(userId: String) { + val userSettings = accountsStore.findByIdOrNull(userId)?.appSettings + if (userSettings?.zapDefault != null && userSettings.zapsConfig.isNotEmpty()) return + + val defaultSettings = fetchDefaultAppSettings(userId = userId) + val defaultZapDefault = defaultSettings?.zapDefault ?: DEFAULT_ZAP_DEFAULT + val defaultZapsConfig = defaultSettings?.zapsConfig ?: DEFAULT_ZAP_CONFIG + + val existingZapDefaultValue = userSettings?.defaultZapAmount + val existingZapsConfigValues = userSettings?.zapOptions + + updateAndPersistAppSettings(userId = userId) { + this.copy( + zapDefault = defaultZapDefault.copy( + amount = existingZapDefaultValue?.toLong() ?: defaultZapDefault.amount, + ), + zapsConfig = if (existingZapsConfigValues.isNullOrEmpty()) { + defaultZapsConfig + } else { + defaultZapsConfig.toMutableList().apply { + this[0] = this[0].copy(amount = existingZapsConfigValues[0].toLong()) + this[1] = this[1].copy(amount = existingZapsConfigValues[1].toLong()) + this[2] = this[2].copy(amount = existingZapsConfigValues[2].toLong()) + this[3] = this[3].copy(amount = existingZapsConfigValues[3].toLong()) + this[4] = this[4].copy(amount = existingZapsConfigValues[4].toLong()) + this[5] = this[5].copy(amount = existingZapsConfigValues[5].toLong()) + } + }, + ) + } + } + private suspend fun updateAndPersistAppSettings( userId: String, reducer: ContentAppSettings.() -> ContentAppSettings, @@ -104,8 +145,8 @@ class SettingsRepository @Inject constructor( } private suspend fun persistAppSettings(userId: String, appSettings: ContentAppSettings) { - val currentUserAccount = - accountsStore.findByIdOrNull(userId = userId) ?: UserAccount.buildLocal(pubkey = userId) + val currentUserAccount = accountsStore.findByIdOrNull(userId = userId) + ?: UserAccount.buildLocal(pubkey = userId) val userFeeds = appSettings.feeds.distinctBy { it.directive }.map { it.asFeedPO() } val hasLatestFeed = userFeeds.find { it.directive == userId } != null diff --git a/app/src/main/kotlin/net/primal/android/settings/wallet/WalletSettingsScreen.kt b/app/src/main/kotlin/net/primal/android/settings/wallet/WalletSettingsScreen.kt index 3ca8f8d60..48115442e 100644 --- a/app/src/main/kotlin/net/primal/android/settings/wallet/WalletSettingsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/settings/wallet/WalletSettingsScreen.kt @@ -99,6 +99,8 @@ fun WalletSettingsScreen( verticalArrangement = Arrangement.Center, horizontalAlignment = Alignment.CenterHorizontally, ) { + Spacer(modifier = Modifier.height(16.dp)) + ExternalWalletSettings( nwcWallet = state.wallet, walletPreference = state.walletPreference, diff --git a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsContract.kt b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsContract.kt index 346c08415..85dc20994 100644 --- a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsContract.kt +++ b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsContract.kt @@ -1,25 +1,28 @@ package net.primal.android.settings.zaps +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.ContentZapDefault + interface ZapSettingsContract { data class UiState( - val defaultZapAmount: ULong? = null, - val zapOptions: List = List(PRESETS_COUNT) { null }, + val editPresetIndex: Int? = null, + val saving: Boolean = false, + val zapDefault: ContentZapDefault? = null, + val zapConfig: List = emptyList(), ) sealed class UiEvent { - data class ZapOptionsChanged(val newOptions: List) : UiEvent() - data class ZapDefaultAmountChanged(val newAmount: ULong?) : UiEvent() + data object EditZapDefault : UiEvent() + data class EditZapPreset(val preset: ContentZapConfigItem) : UiEvent() + data object CloseEditor : UiEvent() + data class UpdateZapPreset( + val index: Int, + val zapPreset: ContentZapConfigItem, + ) : UiEvent() + + data class UpdateZapDefault(val newZapDefault: ContentZapDefault) : UiEvent() } } const val PRESETS_COUNT = 6 - -val DEFAULT_ZAP_OPTIONS = listOf( - 21.toULong(), - 420.toULong(), - 1_000.toULong(), - 5_000.toULong(), - 10_000.toULong(), - 100_000.toULong(), -) diff --git a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsScreen.kt b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsScreen.kt index 8fdd52233..a069aa000 100644 --- a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsScreen.kt +++ b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsScreen.kt @@ -1,39 +1,74 @@ package net.primal.android.settings.zaps +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith import androidx.compose.foundation.background -import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowForwardIos +import androidx.compose.material3.Card import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.core.text.isDigitsOnly +import androidx.emoji2.emojipicker.EmojiPickerView +import java.text.NumberFormat import net.primal.android.R -import net.primal.android.core.compose.PrimalDivider +import net.primal.android.core.compose.PrimalDefaults import net.primal.android.core.compose.PrimalTopAppBar +import net.primal.android.core.compose.button.PrimalLoadingButton import net.primal.android.core.compose.icons.PrimalIcons import net.primal.android.core.compose.icons.primaliconpack.ArrowBack +import net.primal.android.core.compose.icons.primaliconpack.FeedZaps +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.ContentZapDefault +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_CONFIG +import net.primal.android.nostr.model.primal.content.DEFAULT_ZAP_DEFAULT import net.primal.android.theme.AppTheme +import net.primal.android.theme.PrimalTheme @Composable fun ZapSettingsScreen(viewModel: ZapSettingsViewModel, onClose: () -> Unit) { @@ -52,45 +87,89 @@ fun ZapSettingsScreen( onClose: () -> Unit, eventPublisher: (ZapSettingsContract.UiEvent) -> Unit, ) { + val backSequence = { + if (uiState.editPresetIndex == null) { + onClose() + } else { + eventPublisher(ZapSettingsContract.UiEvent.CloseEditor) + } + } + + BackHandler(enabled = uiState.editPresetIndex != null) { + backSequence() + } + Scaffold( modifier = Modifier, topBar = { PrimalTopAppBar( title = stringResource(id = R.string.settings_zaps_title), navigationIcon = PrimalIcons.ArrowBack, - onNavigationIconClick = onClose, + onNavigationIconClick = backSequence, ) }, content = { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(horizontal = 16.dp, vertical = 16.dp) - .imePadding(), - verticalArrangement = Arrangement.Top, - horizontalAlignment = Alignment.Start, + AnimatedContent( + targetState = uiState.editPresetIndex, + transitionSpec = { + when (targetState) { + null -> { + slideInHorizontally(initialOffsetX = { -it }) + .togetherWith( + slideOutHorizontally(targetOffsetX = { it }), + ) + } + + else -> { + slideInHorizontally(initialOffsetX = { it }) + .togetherWith(slideOutHorizontally(targetOffsetX = { -it })) + } + } + }, + label = "ZapSettings", ) { - item { - ZapDefaultAmount( - defaultZapAmount = uiState.defaultZapAmount, - onDefaultZapAmountChanged = { - eventPublisher(ZapSettingsContract.UiEvent.ZapDefaultAmountChanged(newAmount = it)) - }, - ) - } + when (it) { + null -> { + ZapPresetsList( + paddingValues = paddingValues, + zapDefault = uiState.zapDefault, + zapsConfig = uiState.zapConfig, + onZapDefaultClick = { + eventPublisher(ZapSettingsContract.UiEvent.EditZapDefault) + }, + onPresetClick = { presetItem -> + eventPublisher(ZapSettingsContract.UiEvent.EditZapPreset(presetItem)) + }, + ) + } - item { - ZapOptionDashboard( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), - zapOptions = uiState.zapOptions, - editable = true, - onZapOptionsChanged = { - eventPublisher(ZapSettingsContract.UiEvent.ZapOptionsChanged(newOptions = it)) - }, - ) + -1 -> { + ZapDefaultEditor( + paddingValues = paddingValues, + zapDefault = uiState.zapDefault!!, + onUpdate = { + eventPublisher(ZapSettingsContract.UiEvent.UpdateZapDefault(it)) + }, + updating = uiState.saving, + ) + } + + in (0..PRESETS_COUNT) -> { + ZapPresetEditor( + paddingValues = paddingValues, + index = it, + zapsConfig = uiState.zapConfig, + updating = uiState.saving, + onUpdate = { zapPreset -> + eventPublisher( + ZapSettingsContract.UiEvent.UpdateZapPreset( + index = it, + zapPreset = zapPreset, + ), + ) + }, + ) + } } } }, @@ -98,219 +177,382 @@ fun ZapSettingsScreen( } @Composable -private fun ZapDefaultAmount(defaultZapAmount: ULong?, onDefaultZapAmountChanged: (ULong?) -> Unit) { - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource( - id = R.string.settings_zaps_default_zap_amount_header, - ).uppercase(), - style = AppTheme.typography.bodySmall, - ) +fun ZapPresetsList( + paddingValues: PaddingValues, + zapDefault: ContentZapDefault?, + zapsConfig: List = emptyList(), + onZapDefaultClick: () -> Unit, + onPresetClick: (ContentZapConfigItem) -> Unit, +) { + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .imePadding(), + verticalArrangement = Arrangement.Top, + horizontalAlignment = Alignment.Start, + ) { + item { + Spacer(modifier = Modifier.height(16.dp)) + } - OutlinedTextField( - modifier = Modifier.fillMaxWidth(fraction = 0.3f), - value = defaultZapAmount?.toString() ?: "", - onValueChange = { - if (it.isDigitsOnly()) { - onDefaultZapAmountChanged(it.toULongOrNull()) - } - }, - enabled = true, - textStyle = AppTheme.typography.bodyLarge.copy( - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - autoCorrect = false, - ), - shape = AppTheme.shapes.small, - colors = zapTextFieldColors(), - ) + if (zapDefault != null) { + item { + ZapDefaultListItem( + modifier = Modifier + .padding(horizontal = 16.dp) + .clickable { + onZapDefaultClick() + }, + text = zapDefault.message, + amount = zapDefault.amount, + ) - PrimalDivider( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 16.dp), - ) + Text( + modifier = Modifier.padding(horizontal = 20.dp, vertical = 8.dp), + text = stringResource(id = R.string.settings_zaps_description), + style = AppTheme.typography.bodySmall, + color = AppTheme.extraColorScheme.onSurfaceVariantAlt3, + ) + } + } - Text( - modifier = Modifier.padding(bottom = 16.dp), - text = stringResource( - id = R.string.settings_zaps_custom_zaps_header, - ).uppercase(), - style = AppTheme.typography.bodySmall, - ) + if (zapsConfig.isNotEmpty()) { + item { + ZapCustomPresets( + modifier = Modifier.padding(horizontal = 16.dp), + presets = zapsConfig, + onPresetClick = { + onPresetClick(it) + }, + ) + } + } + } } @Composable -fun ZapOptionDashboard( +private fun ZapDefaultListItem( modifier: Modifier, - zapOptions: List, - editable: Boolean = true, - zapEmojis: List = listOf( - "\uD83D\uDC4D", - "\uD83C\uDF3F", - "\uD83E\uDD19", - "\uD83D\uDC9C", - "\uD83D\uDD25", - "\uD83D\uDE80", - ), - onZapOptionsChanged: (List) -> Unit, + text: String, + amount: Long, ) { - Row( - modifier = modifier, - ) { - ZapOption( - modifier = Modifier - .weight(1f) - .padding(end = 8.dp), - emoji = zapEmojis[0], - amount = zapOptions.getOrNull(0), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 0, amount = it)) + val numberFormat = remember { NumberFormat.getNumberInstance() } + Card(modifier = modifier) { + ListItem( + colors = ListItemDefaults.colors( + containerColor = AppTheme.extraColorScheme.surfaceVariantAlt1, + ), + leadingContent = { + Icon(imageVector = PrimalIcons.FeedZaps, contentDescription = null) }, - ) - - ZapOption( - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), - emoji = zapEmojis[1], - amount = zapOptions.getOrNull(1), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 1, amount = it)) + headlineContent = { + Text( + text = text, + color = AppTheme.colorScheme.onPrimary, + ) }, - ) - ZapOption( - modifier = Modifier - .weight(1f) - .padding(start = 8.dp), - emoji = zapEmojis[2], - amount = zapOptions.getOrNull(2), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 2, amount = it)) + supportingContent = { + Text( + modifier = Modifier.padding(vertical = 2.dp), + text = "${numberFormat.format(amount)} sats", + color = AppTheme.extraColorScheme.onSurfaceVariantAlt1, + ) + }, + trailingContent = { + Icon(imageVector = Icons.Default.ArrowForwardIos, contentDescription = null) }, ) } +} + +@Composable +private fun ZapCustomPresets( + modifier: Modifier, + presets: List, + onPresetClick: (ContentZapConfigItem) -> Unit, +) { + val numberFormat = remember { NumberFormat.getNumberInstance() } + Card(modifier = modifier) { + presets.forEach { zapItem -> + ListItem( + modifier = Modifier.clickable { + onPresetClick(zapItem) + }, + colors = ListItemDefaults.colors( + containerColor = AppTheme.extraColorScheme.surfaceVariantAlt1, + ), + leadingContent = { + Text(text = zapItem.emoji) + }, + headlineContent = { + Text( + text = zapItem.message, + color = AppTheme.colorScheme.onPrimary, + ) + }, + supportingContent = { + Text( + modifier = Modifier.padding(vertical = 2.dp), + text = "${numberFormat.format(zapItem.amount)} sats", + color = AppTheme.extraColorScheme.onSurfaceVariantAlt1, + ) + }, + trailingContent = { + Icon(imageVector = Icons.Default.ArrowForwardIos, contentDescription = null) + }, + ) + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ZapPresetEditor( + paddingValues: PaddingValues, + updating: Boolean, + index: Int, + zapsConfig: List, + onUpdate: (ContentZapConfigItem) -> Unit, +) { + val zapConfig = zapsConfig[index] - Row( + var emoji by remember { mutableStateOf(zapConfig.emoji) } + var message by remember { mutableStateOf(zapConfig.message) } + var amount by remember { mutableStateOf(zapConfig.amount.toString()) } + + Column( modifier = Modifier - .fillMaxWidth() - .padding(vertical = 8.dp), + .verticalScroll(rememberScrollState()) + .padding(paddingValues), ) { - ZapOption( - modifier = Modifier - .weight(1f) - .padding(end = 8.dp), - emoji = zapEmojis[3], - amount = zapOptions.getOrNull(3), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 3, amount = it)) + ZapPresetForm( + emojiValue = emoji, + onEmojiValueChange = { + emoji = it }, - ) - - ZapOption( - modifier = Modifier - .weight(1f) - .padding(horizontal = 8.dp), - emoji = zapEmojis[4], - amount = zapOptions.getOrNull(4), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 4, amount = it)) + messageValue = message, + onMessageValueChanged = { + message = it + }, + amountValue = amount, + onAmountValueChanged = { + when { + it.isEmpty() -> amount = "" + it.isDigitsOnly() && it.length <= 8 && it.toLong() > 0 -> amount = it + } }, ) - ZapOption( + + Spacer(modifier = Modifier.height(24.dp)) + + val keyboard = LocalSoftwareKeyboardController.current + PrimalLoadingButton( modifier = Modifier - .weight(1f) - .padding(start = 8.dp), - emoji = zapEmojis[5], - amount = zapOptions.getOrNull(5), - editable = editable, - onZapAmountChanged = { - onZapOptionsChanged(zapOptions.copy(index = 5, amount = it)) + .padding(horizontal = 32.dp) + .fillMaxWidth(), + enabled = emoji.isEmoji() && message.isNotEmpty() && amount.toLongOrNull() != null, + loading = updating, + text = stringResource(id = R.string.settings_zaps_editor_save), + onClick = { + keyboard?.hide() + onUpdate( + ContentZapConfigItem( + emoji = emoji, + message = message, + amount = amount.toLong(), + ), + ) }, ) } } -private fun List.copy(index: Int, amount: ULong?): List { - val newOptions = this.toMutableList() - newOptions[index] = amount - return newOptions +@Composable +private fun ZapPresetForm( + messageValue: String, + onMessageValueChanged: (String) -> Unit, + amountValue: String, + onAmountValueChanged: (String) -> Unit, + emojiValue: String? = null, + onEmojiValueChange: ((String) -> Unit)? = null, +) { + if (emojiValue != null && onEmojiValueChange != null) { + var emojiPickerVisible by remember { mutableStateOf(false) } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(id = R.string.settings_zaps_editor_emoji).uppercase(), + style = AppTheme.typography.bodySmall, + ) + + Box( + modifier = Modifier + .width(96.dp) + .height(56.dp) + .padding(horizontal = 16.dp) + .background(color = AppTheme.extraColorScheme.surfaceVariantAlt1, shape = AppTheme.shapes.medium) + .clickable { + emojiPickerVisible = true + }, + contentAlignment = Alignment.Center, + ) { + Text( + text = emojiValue, + style = AppTheme.typography.bodyMedium.copy(fontSize = 24.sp), + ) + } + + if (emojiPickerVisible) { + EmojiPicker( + onEmojiSelected = { + onEmojiValueChange(it) + }, + onDismissRequest = { + emojiPickerVisible = false + }, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(id = R.string.settings_zaps_editor_message).uppercase(), + style = AppTheme.typography.bodySmall, + ) + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + colors = PrimalDefaults.outlinedTextFieldColors(), + shape = AppTheme.shapes.medium, + value = messageValue, + onValueChange = onMessageValueChanged, + textStyle = AppTheme.typography.bodyMedium, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + text = stringResource(id = R.string.settings_zaps_editor_value).uppercase(), + style = AppTheme.typography.bodySmall, + ) + + OutlinedTextField( + modifier = Modifier + .padding(horizontal = 16.dp) + .fillMaxWidth(), + colors = PrimalDefaults.outlinedTextFieldColors(), + shape = AppTheme.shapes.medium, + singleLine = true, + value = amountValue, + onValueChange = onAmountValueChanged, + textStyle = AppTheme.typography.bodyMedium, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + ) } +private fun String.isEmoji(): Boolean = this.isNotEmpty() + +@OptIn(ExperimentalComposeUiApi::class) @Composable -fun ZapOption( - modifier: Modifier, - emoji: String, - amount: ULong?, - editable: Boolean = true, - onZapAmountChanged: (ULong?) -> Unit, +fun ZapDefaultEditor( + paddingValues: PaddingValues, + zapDefault: ContentZapDefault, + onUpdate: (ContentZapDefault) -> Unit, + updating: Boolean, ) { - Column(modifier = modifier) { - Text( + var message by remember { mutableStateOf(zapDefault.message) } + var amount by remember { mutableStateOf(zapDefault.amount.toString()) } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(paddingValues), + ) { + ZapPresetForm( + messageValue = message, + onMessageValueChanged = { + message = it + }, + amountValue = amount, + onAmountValueChanged = { + when { + it.isEmpty() -> amount = "" + it.isDigitsOnly() && it.length <= 8 && it.toLong() > 0 -> amount = it + } + }, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + val keyboard = LocalSoftwareKeyboardController.current + PrimalLoadingButton( modifier = Modifier - .background( - color = AppTheme.extraColorScheme.surfaceVariantAlt1, - shape = AppTheme.shapes.small, - ) - .border( - width = 1.dp, - color = AppTheme.colorScheme.outline, - shape = AppTheme.shapes.small.copy( - bottomStart = CornerSize(0.dp), - bottomEnd = CornerSize(0.dp), - ), + .padding(horizontal = 32.dp) + .fillMaxWidth(), + enabled = message.isNotEmpty() && amount.toLongOrNull() != null, + loading = updating, + text = stringResource(id = R.string.settings_zaps_editor_save), + onClick = { + keyboard?.hide() + onUpdate( + ContentZapDefault(amount = amount.toLong(), message = message), ) - .fillMaxWidth() - .padding(top = 16.dp, bottom = 20.dp), - text = emoji, - textAlign = TextAlign.Center, - fontSize = 28.sp, + }, ) + } +} - OutlinedTextField( - modifier = Modifier.padding(top = 4.dp), - value = amount?.toString() ?: "", - onValueChange = { - if (it.isDigitsOnly()) { - onZapAmountChanged(it.toULongOrNull()) - } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun EmojiPicker(onEmojiSelected: (String) -> Unit, onDismissRequest: () -> Unit) { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + ) { + AndroidView( + modifier = Modifier + .fillMaxWidth() + .wrapContentHeight() + .verticalScroll(rememberScrollState()) + .offset(y = (-32).dp), + factory = { + EmojiPickerView(it) + .apply { + this.emojiGridColumns = 9 + setOnEmojiPickedListener { item -> + onEmojiSelected(item.emoji) + onDismissRequest() + } + } }, - enabled = editable, - textStyle = AppTheme.typography.bodyLarge.copy( - textAlign = TextAlign.Center, - fontWeight = FontWeight.Bold, - ), - shape = AppTheme.shapes.small.copy( - topStart = CornerSize(0.dp), - topEnd = CornerSize(0.dp), - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - autoCorrect = false, - ), - colors = zapTextFieldColors(), + update = {}, ) } } +@Preview @Composable -private fun zapTextFieldColors() = - OutlinedTextFieldDefaults.colors( - unfocusedContainerColor = AppTheme.extraColorScheme.surfaceVariantAlt1, - focusedContainerColor = AppTheme.extraColorScheme.surfaceVariantAlt1, - disabledContainerColor = AppTheme.extraColorScheme.surfaceVariantAlt1, - unfocusedBorderColor = AppTheme.colorScheme.outline, - focusedBorderColor = AppTheme.extraColorScheme.onSurfaceVariantAlt4, - disabledBorderColor = AppTheme.colorScheme.outline, - disabledTextColor = AppTheme.extraColorScheme.onSurfaceVariantAlt2, - focusedTextColor = AppTheme.extraColorScheme.onSurfaceVariantAlt2, - unfocusedTextColor = AppTheme.extraColorScheme.onSurfaceVariantAlt2, - ) +private fun ZapSettingsPreview() { + PrimalTheme(primalTheme = net.primal.android.theme.domain.PrimalTheme.Sunset) { + ZapSettingsScreen( + uiState = ZapSettingsContract.UiState( + editPresetIndex = 0, + zapDefault = DEFAULT_ZAP_DEFAULT, + zapConfig = DEFAULT_ZAP_CONFIG, + ), + onClose = {}, + eventPublisher = {}, + ) + } +} diff --git a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsViewModel.kt b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsViewModel.kt index dc2969b33..b85527ae0 100644 --- a/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/settings/zaps/ZapSettingsViewModel.kt @@ -4,24 +4,26 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlin.time.Duration.Companion.seconds -import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.getAndUpdate import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import net.primal.android.core.coroutines.CoroutineDispatcherProvider import net.primal.android.networking.sockets.errors.WssException +import net.primal.android.nostr.model.primal.content.ContentZapConfigItem +import net.primal.android.nostr.model.primal.content.ContentZapDefault import net.primal.android.settings.repository.SettingsRepository import net.primal.android.settings.zaps.ZapSettingsContract.UiEvent import net.primal.android.settings.zaps.ZapSettingsContract.UiState import net.primal.android.user.accounts.active.ActiveAccountStore +import timber.log.Timber @HiltViewModel class ZapSettingsViewModel @Inject constructor( + private val dispatcherProvider: CoroutineDispatcherProvider, private val activeAccountStore: ActiveAccountStore, private val settingsRepository: SettingsRepository, ) : ViewModel() { @@ -37,48 +39,34 @@ class ZapSettingsViewModel @Inject constructor( fetchLatestAppSettings() observeEvents() observeActiveAccount() - observeDebouncedZapOptionChanges() - observeDebouncedZapDefaultAmountChanges() } private fun observeEvents() = viewModelScope.launch { events.collect { when (it) { - is UiEvent.ZapDefaultAmountChanged -> setState { - copy( - defaultZapAmount = it.newAmount, - ) + UiEvent.EditZapDefault -> { + setState { copy(editPresetIndex = -1) } } - is UiEvent.ZapOptionsChanged -> setState { copy(zapOptions = it.newOptions) } - } - } - } - @OptIn(FlowPreview::class) - private fun observeDebouncedZapOptionChanges() = - viewModelScope.launch { - events.filterIsInstance() - .debounce(1.seconds) - .mapNotNull { it.newOptions.toListOfULongsOrNull() } - .collect { - updateZapOptions(newZapOptions = it) - } - } + is UiEvent.EditZapPreset -> { + val index = _state.value.zapConfig.indexOf(it.preset) + setState { copy(editPresetIndex = index) } + } - private fun List.toListOfULongsOrNull(): List? { - return if (this.contains(null)) null else mapNotNull { it } - } + UiEvent.CloseEditor -> { + setState { copy(editPresetIndex = null) } + } - @OptIn(FlowPreview::class) - private fun observeDebouncedZapDefaultAmountChanges() = - viewModelScope.launch { - events.filterIsInstance() - .debounce(1.seconds) - .mapNotNull { it.newAmount } - .collect { - updateDefaultZapAmount(newDefaultAmount = it) + is UiEvent.UpdateZapPreset -> { + updateZapPreset(presetIndex = it.index, zapPreset = it.zapPreset) + } + + is UiEvent.UpdateZapDefault -> { + updateDefaultZapAmount(newZapDefault = it.newZapDefault) + } } + } } private fun observeActiveAccount() = @@ -87,14 +75,7 @@ class ZapSettingsViewModel @Inject constructor( .mapNotNull { it.appSettings } .collect { setState { - copy( - defaultZapAmount = it.defaultZapAmount ?: 42.toULong(), - zapOptions = if (it.zapOptions.size == PRESETS_COUNT) { - it.zapOptions - } else { - List(PRESETS_COUNT) { null } - }, - ) + copy(zapDefault = it.zapDefault, zapConfig = it.zapsConfig) } } } @@ -102,35 +83,46 @@ class ZapSettingsViewModel @Inject constructor( private fun fetchLatestAppSettings() = viewModelScope.launch { try { - settingsRepository.fetchAndPersistAppSettings( - userId = activeAccountStore.activeUserId(), - ) + withContext(dispatcherProvider.io()) { + settingsRepository.fetchAndPersistAppSettings(userId = activeAccountStore.activeUserId()) + } } catch (error: WssException) { - // Ignore + Timber.e(error) } } - private suspend fun updateDefaultZapAmount(newDefaultAmount: ULong) { - try { - val userAccount = activeAccountStore.activeUserAccount() - settingsRepository.updateAndPersistDefaultZapAmount( - userId = userAccount.pubkey, - defaultAmount = newDefaultAmount, - ) - } catch (error: WssException) { - // Something went wrong + private suspend fun updateDefaultZapAmount(newZapDefault: ContentZapDefault) = + viewModelScope.launch { + setState { copy(saving = true) } + try { + val userAccount = activeAccountStore.activeUserAccount() + settingsRepository.updateAndPersistZapDefault( + userId = userAccount.pubkey, + zapDefault = newZapDefault, + ) + setState { copy(editPresetIndex = null) } + } catch (error: WssException) { + Timber.e(error) + } finally { + setState { copy(saving = false) } + } } - } - private suspend fun updateZapOptions(newZapOptions: List) { - try { - val userAccount = activeAccountStore.activeUserAccount() - settingsRepository.updateAndPersistZapOptions( - userId = userAccount.pubkey, - zapOptions = newZapOptions, - ) - } catch (error: WssException) { - // Something went wrong + private suspend fun updateZapPreset(presetIndex: Int, zapPreset: ContentZapConfigItem) = + viewModelScope.launch { + setState { copy(saving = true) } + try { + val userAccount = activeAccountStore.activeUserAccount() + settingsRepository.updateAndPersistZapPresetsConfig( + userId = userAccount.pubkey, + presetIndex = presetIndex, + zapPreset = zapPreset, + ) + setState { copy(editPresetIndex = null) } + } catch (error: WssException) { + Timber.e(error) + } finally { + setState { copy(saving = false) } + } } - } } diff --git a/app/src/main/kotlin/net/primal/android/thread/ThreadScreen.kt b/app/src/main/kotlin/net/primal/android/thread/ThreadScreen.kt index d51a85637..de6038054 100644 --- a/app/src/main/kotlin/net/primal/android/thread/ThreadScreen.kt +++ b/app/src/main/kotlin/net/primal/android/thread/ThreadScreen.kt @@ -188,7 +188,7 @@ fun ThreadScreen( ThreadContract.UiEvent.ZapAction( postId = post.postId, postAuthorId = post.authorId, - zapAmount = zapAmount, + zapAmount = zapAmount.toULong(), zapDescription = zapDescription, ), ) diff --git a/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt b/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt index b2a8d918d..a36a5bf0b 100644 --- a/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt +++ b/app/src/main/kotlin/net/primal/android/thread/ThreadViewModel.kt @@ -101,9 +101,8 @@ class ThreadViewModel @Inject constructor( zappingState = this.zappingState.copy( walletConnected = it.data.hasWallet(), walletPreference = it.data.walletPreference, - defaultZapAmount = it.data.appSettings?.defaultZapAmount - ?: this.zappingState.defaultZapAmount, - zapOptions = it.data.appSettings?.zapOptions ?: this.zappingState.zapOptions, + zapDefault = it.data.appSettings?.zapDefault ?: this.zappingState.zapDefault, + zapsConfig = it.data.appSettings?.zapsConfig ?: this.zappingState.zapsConfig, walletBalanceInBtc = it.data.primalWalletBalanceInBtc, ), ) diff --git a/app/src/main/kotlin/net/primal/android/user/updater/UserDataUpdater.kt b/app/src/main/kotlin/net/primal/android/user/updater/UserDataUpdater.kt index 4dcd68a62..0340af210 100644 --- a/app/src/main/kotlin/net/primal/android/user/updater/UserDataUpdater.kt +++ b/app/src/main/kotlin/net/primal/android/user/updater/UserDataUpdater.kt @@ -36,6 +36,7 @@ class UserDataUpdater @AssistedInject constructor( private suspend fun updateData() { settingsRepository.fetchAndPersistAppSettings(userId = userId) + settingsRepository.ensureZapConfig(userId = userId) userRepository.fetchAndUpdateUserAccount(userId = userId) walletRepository.fetchUserWalletInfoAndUpdateUserAccount(userId = userId) } diff --git a/app/src/main/kotlin/net/primal/android/wallet/zaps/ZappingExt.kt b/app/src/main/kotlin/net/primal/android/wallet/zaps/ZappingExt.kt index 6182fa53b..570a286ff 100644 --- a/app/src/main/kotlin/net/primal/android/wallet/zaps/ZappingExt.kt +++ b/app/src/main/kotlin/net/primal/android/wallet/zaps/ZappingExt.kt @@ -13,9 +13,9 @@ fun UserAccount.hasWallet(): Boolean { } } -fun ZappingState.canZap(zapAmount: ULong = this.defaultZapAmount): Boolean { +fun ZappingState.canZap(zapAmount: Long = this.zapDefault.amount): Boolean { return walletConnected && when (walletPreference) { WalletPreference.NostrWalletConnect -> true - else -> (walletBalanceInBtc == null || walletBalanceInBtc.toSats() >= zapAmount) + else -> (walletBalanceInBtc == null || walletBalanceInBtc.toSats() >= zapAmount.toULong()) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 126dc469d..e3b448256 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -285,8 +285,11 @@ Dismiss Zaps - Set default zap amount - Set custom zap amounts + Single tap zaps send the default amount & message. Configure custom zap presets below: + Emoji + Message + Value + Save Muted Accounts Unmute @@ -315,6 +318,7 @@ Unmute user Add a comment… + Custom amount… Zap To zap people on Nostr, you need to enable your Primal wallet or connect the external wallet. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56ce6ff7f..ba193b3f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,6 +16,7 @@ core-testing = "2.2.0" dagger = "2.48" datastore = "1.0.0" detekt = "1.23.3" +emoji2-emojipicker = "1.4.0" espresso-core = "3.5.1" hilt = "1.1.0" junit = "4.13.2" @@ -82,6 +83,8 @@ compose-pager-indicators = { module = "com.google.accompanist:accompanist-pager- constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } compose-constraintlayout = { module = "androidx.constraintlayout:constraintlayout-compose", version.ref = "constraintlayout-compose" } +androidx-emoji2-emojipicker = { group = "androidx.emoji2", name = "emoji2-emojipicker", version.ref = "emoji2-emojipicker" } + lottie-compose = { module = "com.airbnb.android:lottie-compose", version.ref = "compose-lottie" } navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "compose-navigation" } navigation-hilt = { module = "androidx.hilt:hilt-navigation-compose", version.ref = "hilt" }