taler-android

Android apps for GNU Taler (wallet, PoS, cashier)
Log | Files | Refs | README | LICENSE

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 }