diff --git a/app/src/main/java/to/bitkit/di/HttpModule.kt b/app/src/main/java/to/bitkit/di/HttpModule.kt index f652939b3..4a1f551c6 100644 --- a/app/src/main/java/to/bitkit/di/HttpModule.kt +++ b/app/src/main/java/to/bitkit/di/HttpModule.kt @@ -18,9 +18,9 @@ import io.ktor.http.contentType import io.ktor.http.isSuccess import io.ktor.serialization.kotlinx.json.json import kotlinx.serialization.json.Json -import to.bitkit.utils.UrlValidator import to.bitkit.utils.AppError import to.bitkit.utils.Logger +import to.bitkit.utils.UrlValidator import javax.inject.Singleton import io.ktor.client.plugins.logging.Logger as KtorLogger diff --git a/app/src/main/java/to/bitkit/ui/Locals.kt b/app/src/main/java/to/bitkit/ui/Locals.kt index 2843e38c4..b766d86af 100644 --- a/app/src/main/java/to/bitkit/ui/Locals.kt +++ b/app/src/main/java/to/bitkit/ui/Locals.kt @@ -4,6 +4,7 @@ import androidx.compose.material3.DrawerState import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.staticCompositionLocalOf +import dev.chrisbanes.haze.HazeState import to.bitkit.models.BalanceState import to.bitkit.repositories.CurrencyState import to.bitkit.viewmodels.ActivityListViewModel @@ -29,6 +30,7 @@ val LocalActivityListViewModel = staticCompositionLocalOf { null } val LocalSettingsViewModel = staticCompositionLocalOf { null } val LocalBackupsViewModel = staticCompositionLocalOf { null } +val LocalHazeState = staticCompositionLocalOf { null } val appViewModel: AppViewModel? @Composable get() = LocalAppViewModel.current @@ -56,3 +58,6 @@ val backupsViewModel: BackupsViewModel? val drawerState: DrawerState? @Composable get() = LocalDrawerState.current + +val hazeState: HazeState? + @Composable get() = LocalHazeState.current diff --git a/app/src/main/java/to/bitkit/ui/components/Button.kt b/app/src/main/java/to/bitkit/ui/components/Button.kt index 49cc8958f..dc91fb4f8 100644 --- a/app/src/main/java/to/bitkit/ui/components/Button.kt +++ b/app/src/main/java/to/bitkit/ui/components/Button.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeight @@ -24,6 +25,7 @@ import androidx.compose.material3.Text 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.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer @@ -33,11 +35,16 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.HazeStyle +import dev.chrisbanes.haze.HazeTint +import dev.chrisbanes.haze.hazeEffect +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import to.bitkit.R import to.bitkit.ui.shared.modifiers.alphaFeedback import to.bitkit.ui.shared.modifiers.clickableAlpha import to.bitkit.ui.shared.modifiers.rememberDebouncedClick -import to.bitkit.ui.shared.util.glassBlur import to.bitkit.ui.shared.util.primaryButtonStyle import to.bitkit.ui.theme.AppButtonDefaults import to.bitkit.ui.theme.AppThemeSurface @@ -76,12 +83,6 @@ enum class ButtonSize { Small -> 8.dp Large -> 6.dp } - val secondaryBlurRadius: Dp - get() = when (this) { - Small -> 5.dp - Large -> 8.dp - } - fun secondaryBorder(enabled: Boolean): BorderStroke = when (this) { Large -> BorderStroke(2.dp, if (enabled) Colors.Gray4 else Color.Transparent) Small -> BorderStroke(1.dp, if (enabled) Colors.White16 else Color.Transparent) @@ -170,6 +171,7 @@ fun SecondaryButton( size: ButtonSize = ButtonSize.Large, enabled: Boolean = true, fullWidth: Boolean = true, + hazeState: HazeState? = null, ) { val contentPadding = PaddingValues(horizontal = size.secondaryHorizontalPadding.takeIf { text != null } ?: 0.dp) val border = size.secondaryBorder(enabled) @@ -177,46 +179,67 @@ fun SecondaryButton( ButtonSize.Large -> Colors.White80 ButtonSize.Small -> Colors.White64 } - OutlinedButton( - onClick = rememberDebouncedClick(onClick = onClick), - enabled = enabled && !isLoading, - colors = AppButtonDefaults.secondaryColors.copy(contentColor = contentColor), - contentPadding = contentPadding, - border = border, + // hazeEffect must be on a Box wrapper (not OutlinedButton — Material's Surface draws over it) + // and AFTER size modifiers (Haze needs to know dimensions) + val buttonShape = MaterialTheme.shapes.extraLarge + Box( modifier = modifier .then(if (fullWidth) Modifier.fillMaxWidth() else Modifier) .requiredHeight(size.height) - .glassBlur(blurRadius = size.secondaryBlurRadius) - ) { - if (isLoading) { - GradientCircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(size.height / 2) + .clip(buttonShape) + .then( + if (hazeState != null) { + Modifier.hazeEffect( + state = hazeState, + style = HazeStyle( + blurRadius = 12.dp, + backgroundColor = Color.Black, + tint = HazeTint(Color.Black.copy(alpha = 0.2f)), + ), + ) + } else { + Modifier + } ) - } else { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(size.secondaryGap), - ) { - if (icon != null) { - Box( - modifier = if (enabled) { - Modifier - } else { - Modifier.graphicsLayer { - colorFilter = ColorFilter.tint(Colors.White32) + ) { + OutlinedButton( + onClick = rememberDebouncedClick(onClick = onClick), + enabled = enabled && !isLoading, + colors = AppButtonDefaults.secondaryColors.copy(contentColor = contentColor), + contentPadding = contentPadding, + border = border, + modifier = if (fullWidth) Modifier.fillMaxSize() else Modifier, + ) { + if (isLoading) { + GradientCircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(size.height / 2) + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(size.secondaryGap), + ) { + if (icon != null) { + Box( + modifier = if (enabled) { + Modifier + } else { + Modifier.graphicsLayer { + colorFilter = ColorFilter.tint(Colors.White32) + } } + ) { + icon() } - ) { - icon() } - } - text?.let { - Text( - text = text, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + text?.let { + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } @@ -278,7 +301,7 @@ fun TertiaryButton( } } -@Preview(showBackground = true) +@Preview @Composable private fun PrimaryButtonPreview() { AppThemeSurface { @@ -405,33 +428,39 @@ private fun PrimaryButtonPreview() { } } -@Preview(showBackground = true) +@Preview @Composable private fun SecondaryButtonPreview() { + val hazeState = rememberHazeState() AppThemeSurface { Box { - Image( - painter = painterResource(R.drawable.lightning), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - ) + Box( + modifier = Modifier + .matchParentSize() + .hazeSource(hazeState) + ) { + Image( + painter = painterResource(R.drawable.lightning), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.matchParentSize() + ) + } Column( verticalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.padding(16.dp) ) { - SecondaryButton( - text = "Secondary", - onClick = {}, - ) + SecondaryButton(text = "Secondary", hazeState = hazeState, onClick = {}) SecondaryButton( text = "Secondary With padding", - modifier = Modifier.padding(horizontal = 32.dp), + hazeState = hazeState, onClick = {}, + modifier = Modifier.padding(horizontal = 32.dp) ) SecondaryButton( text = "Secondary With Icon", onClick = {}, + hazeState = hazeState, icon = { Icon( imageVector = Icons.Filled.Favorite, @@ -443,12 +472,14 @@ private fun SecondaryButtonPreview() { SecondaryButton( text = "Secondary Loading", isLoading = true, + hazeState = hazeState, onClick = {}, ) SecondaryButton( text = "Secondary Disabled", onClick = {}, enabled = false, + hazeState = hazeState, icon = { Icon( imageVector = Icons.Filled.Favorite, @@ -461,12 +492,14 @@ private fun SecondaryButtonPreview() { text = "Secondary Small", size = ButtonSize.Small, fullWidth = false, + hazeState = hazeState, onClick = {}, ) SecondaryButton( text = "Secondary Small Loading", size = ButtonSize.Small, isLoading = true, + hazeState = hazeState, onClick = {}, ) SecondaryButton( @@ -485,6 +518,7 @@ private fun SecondaryButtonPreview() { onClick = {}, fullWidth = false, size = ButtonSize.Large, + hazeState = hazeState, icon = { Icon( imageVector = Icons.Filled.Favorite, @@ -513,7 +547,7 @@ private fun SecondaryButtonPreview() { } } -@Preview(showBackground = true) +@Preview @Composable private fun TertiaryButtonPreview() { AppThemeSurface { diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt index 649f4732b..980581b0d 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SavingsWalletScreen.kt @@ -5,13 +5,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -26,6 +31,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import to.bitkit.R @@ -68,22 +75,32 @@ fun SavingsWalletScreen( mutableStateOf(hasFunds && !isGeoBlocked) } + val hazeState = rememberHazeState() Box( modifier = Modifier .fillMaxSize() - .background(Colors.Black) .blockPointerInputPassthrough() ) { - Image( - painter = painterResource(id = R.drawable.piggybank), - contentDescription = null, - contentScale = ContentScale.Fit, + // Background layer: hazeSource must be a sibling of hazeEffect, not a parent. + // Haze can't blur an ancestor — source and effect must be at the same level. + Box( modifier = Modifier - .align(Alignment.TopEnd) - .padding(top = 32.dp) - .offset(x = (120).dp) - .size(268.dp) - ) + .matchParentSize() + .background(Colors.Black) + .hazeSource(hazeState) + ) { + Image( + painter = painterResource(id = R.drawable.piggybank), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(top = 0.dp) + .offset(x = (160).dp) + .size(360.dp) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) + ) + } ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__savings__title), @@ -108,7 +125,7 @@ fun SavingsWalletScreen( IncomingTransfer( amount = balances.balanceInTransferToSavings, remainingDuration = forceCloseRemainingDuration, - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 8.dp) ) } @@ -123,9 +140,10 @@ fun SavingsWalletScreen( Icon( painter = painterResource(R.drawable.ic_transfer), contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp) ) }, + hazeState = hazeState, modifier = Modifier.testTag("TransferToSpending") ) } diff --git a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt index b10954e85..364abb8cf 100644 --- a/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt +++ b/app/src/main/java/to/bitkit/ui/screens/wallets/SpendingWalletScreen.kt @@ -5,13 +5,18 @@ import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Icon import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -26,6 +31,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.synonym.bitkitcore.Activity +import dev.chrisbanes.haze.hazeSource +import dev.chrisbanes.haze.rememberHazeState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import org.lightningdevkit.ldknode.ChannelDetails @@ -70,21 +77,31 @@ fun SpendingWalletScreen( mutableStateOf(hasLnBalance && hasChannels) } + val hazeState = rememberHazeState() Box( modifier = Modifier .fillMaxSize() - .background(Colors.Black) .blockPointerInputPassthrough() ) { - Image( - painter = painterResource(id = R.drawable.coin_stack_x_2), - contentDescription = null, - contentScale = ContentScale.Fit, + // Background layer: hazeSource must be a sibling of hazeEffect, not a parent. + // Haze can't blur an ancestor — source and effect must be at the same level. + Box( modifier = Modifier - .align(Alignment.TopEnd) - .offset(x = (155).dp) - .size(330.dp) - ) + .matchParentSize() + .background(Colors.Black) + .hazeSource(hazeState) + ) { + Image( + painter = painterResource(id = R.drawable.coin_stack_x_2), + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier + .align(Alignment.TopEnd) + .offset(x = (155).dp) + .size(330.dp) + .windowInsetsPadding(WindowInsets.statusBars.only(WindowInsetsSides.Vertical)) + ) + } ScreenColumn(noBackground = true) { AppTopBar( titleText = stringResource(R.string.wallet__spending__title), @@ -123,9 +140,10 @@ fun SpendingWalletScreen( Icon( painter = painterResource(R.drawable.ic_transfer), contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(16.dp) ) }, + hazeState = hazeState, modifier = Modifier.testTag("TransferToSavings") ) } @@ -142,7 +160,8 @@ fun SpendingWalletScreen( } if (showEmptyState) { EmptyStateView( - text = stringResource(R.string.wallet__spending__onboarding).withAccent(accentColor = Colors.Purple), + text = stringResource(R.string.wallet__spending__onboarding) + .withAccent(accentColor = Colors.Purple), modifier = Modifier .systemBarsPadding() .align(Alignment.BottomCenter) diff --git a/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt b/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt index bd84a3389..e50243e66 100644 --- a/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt +++ b/app/src/main/java/to/bitkit/ui/shared/util/Modifiers.kt @@ -156,86 +156,6 @@ private class OuterGlowNode( } } -fun Modifier.glassBlur( - blurRadius: Dp = 8.dp, - tintColor: Color = Color.White, - tintAlpha: Float = 0.05f, - cornerRadius: Dp = 64.dp, -): Modifier = this.then( - GlassBlurElement( - blurRadius = blurRadius, - tintColor = tintColor, - tintAlpha = tintAlpha, - cornerRadius = cornerRadius, - ) -) - -private data class GlassBlurElement( - val blurRadius: Dp, - val tintColor: Color, - val tintAlpha: Float, - val cornerRadius: Dp, -) : ModifierNodeElement() { - override fun create(): GlassBlurNode = GlassBlurNode( - blurRadius = blurRadius, - tintColor = tintColor, - tintAlpha = tintAlpha, - cornerRadius = cornerRadius, - ) - - override fun update(node: GlassBlurNode) { - node.blurRadius = blurRadius - node.tintColor = tintColor - node.tintAlpha = tintAlpha - node.cornerRadius = cornerRadius - } - - override fun InspectorInfo.inspectableProperties() { - name = "glassBlur" - properties["blurRadius"] = blurRadius - properties["tintColor"] = tintColor - properties["tintAlpha"] = tintAlpha - properties["cornerRadius"] = cornerRadius - } -} - -private class GlassBlurNode( - var blurRadius: Dp, - var tintColor: Color, - var tintAlpha: Float, - var cornerRadius: Dp, -) : DrawModifierNode, Modifier.Node() { - override fun ContentDrawScope.draw() { - val blurRadiusPx = blurRadius.toPx() - val cornerRadiusPx = cornerRadius.toPx() - - drawIntoCanvas { canvas -> - val paint = Paint().apply { - isAntiAlias = true - } - - val frameworkPaint = paint.asFrameworkPaint() - frameworkPaint.color = tintColor.copy(alpha = tintAlpha).toArgb() - frameworkPaint.maskFilter = android.graphics.BlurMaskFilter( - blurRadiusPx, - android.graphics.BlurMaskFilter.Blur.NORMAL, - ) - - canvas.drawRoundRect( - left = 0f, - top = 0f, - right = size.width, - bottom = size.height, - radiusX = cornerRadiusPx, - radiusY = cornerRadiusPx, - paint = paint, - ) - } - - drawContent() - } -} - fun Modifier.primaryButtonStyle( isEnabled: Boolean, shape: Shape, diff --git a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt index 392c4b5e1..2eeed6077 100644 --- a/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt +++ b/app/src/main/java/to/bitkit/viewmodels/AppViewModel.kt @@ -73,11 +73,11 @@ import to.bitkit.env.Env import to.bitkit.ext.WatchResult import to.bitkit.ext.amountOnClose import to.bitkit.ext.amountSats +import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.channelId import to.bitkit.ext.claimableAtHeight import to.bitkit.ext.getClipboardText import to.bitkit.ext.getSatsPerVByteFor -import to.bitkit.ext.callbackAmountMsats import to.bitkit.ext.isFixedAmount import to.bitkit.ext.maxSendableSat import to.bitkit.ext.maxWithdrawableSat