AnimatedQrCodeComposable.kt (7781B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2026 Taler Systems S.A. 4 * 5 * GNU Taler is free software; you can redistribute it and/or modify it under the 6 * terms of the GNU General Public License as published by the Free Software 7 * Foundation; either version 3, or (at your option) any later version. 8 * 9 * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY 10 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR 11 * A PARTICULAR PURPOSE. See the GNU General Public License for more details. 12 * 13 * You should have received a copy of the GNU General Public License along with 14 * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/> 15 */ 16 17 package net.taler.lib.android 18 19 import android.graphics.Matrix 20 import android.graphics.Paint 21 import android.graphics.RectF 22 import android.graphics.SweepGradient 23 import androidx.compose.animation.core.LinearEasing 24 import androidx.compose.animation.core.RepeatMode 25 import androidx.compose.animation.core.animateFloat 26 import androidx.compose.animation.core.infiniteRepeatable 27 import androidx.compose.animation.core.rememberInfiniteTransition 28 import androidx.compose.animation.core.tween 29 import androidx.compose.foundation.Canvas 30 import androidx.compose.foundation.Image 31 import androidx.compose.foundation.background 32 import androidx.compose.foundation.layout.Box 33 import androidx.compose.foundation.layout.aspectRatio 34 import androidx.compose.foundation.layout.fillMaxSize 35 import androidx.compose.foundation.layout.size 36 import androidx.compose.foundation.layout.width 37 import androidx.compose.foundation.shape.RoundedCornerShape 38 import androidx.compose.runtime.Composable 39 import androidx.compose.runtime.getValue 40 import androidx.compose.runtime.mutableStateOf 41 import androidx.compose.runtime.produceState 42 import androidx.compose.runtime.remember 43 import androidx.compose.runtime.setValue 44 import androidx.compose.ui.Alignment 45 import androidx.compose.ui.Modifier 46 import androidx.compose.ui.draw.clip 47 import androidx.compose.ui.graphics.Color 48 import androidx.compose.ui.graphics.ImageBitmap 49 import androidx.compose.ui.graphics.asImageBitmap 50 import androidx.compose.ui.graphics.lerp 51 import androidx.compose.ui.graphics.nativeCanvas 52 import androidx.compose.ui.graphics.painter.Painter 53 import androidx.compose.ui.graphics.toArgb 54 import androidx.compose.ui.layout.ContentScale 55 import androidx.compose.ui.layout.onGloballyPositioned 56 import androidx.compose.ui.platform.LocalDensity 57 import androidx.compose.ui.unit.Dp 58 import androidx.compose.ui.unit.dp 59 import androidx.compose.ui.zIndex 60 import androidx.core.graphics.toColorInt 61 import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel 62 import net.taler.lib.android.QrCodeManager.makeQrCode 63 64 const val QR_CORNER_RADIUS = 0.08f 65 const val QR_STRIPE_WIDTH = 0.025f 66 const val QR_DATA_SIZE = 0.88f 67 const val QR_LOGO_SIZE = 0.30f * QR_DATA_SIZE 68 69 @Composable 70 fun AnimatedQrCodeComposable( 71 modifier: Modifier = Modifier, 72 link: String, 73 logoPainter: Painter? = null, 74 qrCornerRadiusFraction: Float = QR_CORNER_RADIUS, 75 ) { 76 val infinite = rememberInfiniteTransition(label = "qrStripe") 77 val angle by infinite.animateFloat( 78 initialValue = 0f, 79 targetValue = 360f, 80 animationSpec = infiniteRepeatable( 81 animation = tween(durationMillis = 5250, easing = LinearEasing), 82 repeatMode = RepeatMode.Restart, 83 ), 84 label = "qrStripeAngle", 85 ) 86 val stripeColor = Color("#3047a3".toColorInt()) // FIXME: load from light-mode primary 87 val stripeBase = Color.White //MaterialTheme.colorScheme.surfaceVariant 88 val stripeSoft = remember(stripeBase, stripeColor) { 89 lerp(stripeBase, stripeColor, 0.55f) 90 } 91 val gradientStops = remember { 92 floatArrayOf(0.00f, 0.05f, 0.09f, 0.12f, 0.16f, 0.21f, 0.50f, 0.55f, 0.59f, 0.62f, 0.66f, 1.00f) 93 } 94 val gradientColors = remember(stripeBase, stripeSoft, stripeColor) { 95 intArrayOf( 96 stripeBase.toArgb(), 97 stripeBase.toArgb(), 98 stripeSoft.toArgb(), 99 stripeColor.toArgb(), 100 stripeSoft.toArgb(), 101 stripeBase.toArgb(), 102 stripeBase.toArgb(), 103 stripeSoft.toArgb(), 104 stripeColor.toArgb(), 105 stripeSoft.toArgb(), 106 stripeBase.toArgb(), 107 stripeBase.toArgb(), 108 ) 109 } 110 val gradientMatrix = remember { Matrix() } 111 val stripePaint = remember { 112 Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } 113 } 114 115 Box( 116 modifier = modifier 117 .fillMaxSize() 118 .aspectRatio(1f), 119 contentAlignment = Alignment.Center, 120 ) { 121 val density = LocalDensity.current 122 var drawSize by remember { mutableStateOf<Dp?>(379.dp) } 123 124 Box( 125 modifier = Modifier 126 .fillMaxSize() 127 .clip(RoundedCornerShape( 128 percent = (qrCornerRadiusFraction * 100).toInt())) 129 .zIndex(-1f) 130 .background(Color.White), //TODO: VLADA design MaterialTheme.colorScheme.surfaceVariant), 131 ) 132 133 Canvas( 134 modifier = Modifier 135 .fillMaxSize() 136 .onGloballyPositioned { with(density) { 137 drawSize = it.size.width.toDp() 138 } }, 139 ) { 140 val s = with(density) { size.width.toDp() } 141 val cornerRadius = s * qrCornerRadiusFraction 142 val stripeWidth = s * QR_STRIPE_WIDTH 143 val stripePx = stripeWidth.toPx() 144 val inset = stripePx / 2f 145 val cornerPx = (cornerRadius.toPx() - inset).coerceAtLeast(0f) 146 val cx = size.width / 2f 147 val cy = size.height / 2f 148 149 val shader = SweepGradient(cx, cy, gradientColors, gradientStops) 150 gradientMatrix.reset() 151 gradientMatrix.setRotate(angle, cx, cy) 152 shader.setLocalMatrix(gradientMatrix) 153 154 stripePaint.strokeWidth = stripePx 155 stripePaint.shader = shader 156 157 drawContext.canvas.nativeCanvas.drawRoundRect( 158 RectF(inset, inset, size.width - inset, size.height - inset), 159 cornerPx, 160 cornerPx, 161 stripePaint, 162 ) 163 } 164 165 drawSize?.let { drawSize -> 166 val qrSize = drawSize * QR_DATA_SIZE 167 val qrSizePx = with(density) { 168 qrSize.roundToPx().coerceAtLeast(256) } 169 val logoSize = drawSize * QR_LOGO_SIZE 170 171 val qrBitmap by produceState<ImageBitmap?>(null) { 172 value = makeQrCode( 173 text = link, 174 size = qrSizePx, 175 margin = 0, 176 errorCorrection = ErrorCorrectionLevel.H, 177 centerLogo = null, 178 centerLogoSize = null, 179 drawBackground = true, 180 darkColor = android.graphics.Color.BLACK, 181 lightColor = android.graphics.Color.WHITE, 182 trimQuietZone = true, 183 ).asImageBitmap() 184 } 185 186 qrBitmap?.let { qr -> 187 Image( 188 bitmap = qr, 189 contentDescription = null, 190 contentScale = ContentScale.FillBounds, 191 modifier = Modifier 192 .size(qrSize) 193 .background(Color.White), 194 ) 195 196 if (logoPainter != null) { 197 Image( 198 painter = logoPainter, 199 contentDescription = null, 200 modifier = Modifier.width(logoSize), 201 ) 202 } 203 } 204 } 205 } 206 }