summaryrefslogtreecommitdiff
path: root/wallet/src/main/java/net/taler/wallet/compose
diff options
context:
space:
mode:
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/compose')
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt226
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt34
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt70
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt153
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt46
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt67
-rw-r--r--wallet/src/main/java/net/taler/wallet/compose/Utils.kt64
7 files changed, 660 insertions, 0 deletions
diff --git a/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
new file mode 100644
index 0000000..a524d1b
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/AmountInputField.kt
@@ -0,0 +1,226 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import android.os.Build
+import androidx.compose.foundation.text.KeyboardActions
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.OffsetMapping
+import androidx.compose.ui.text.input.TransformedText
+import androidx.compose.ui.text.input.VisualTransformation
+import net.taler.common.Amount
+import java.text.DecimalFormat
+import java.text.DecimalFormatSymbols
+import kotlin.math.max
+import kotlin.math.pow
+import kotlin.math.roundToLong
+
+const val DEFAULT_INPUT_DECIMALS = 2
+
+@Composable
+fun AmountInputField(
+ value: String,
+ onValueChange: (value: String) -> Unit,
+ modifier: Modifier = Modifier,
+ label: @Composable (() -> Unit)? = null,
+ supportingText: @Composable (() -> Unit)? = null,
+ isError: Boolean = false,
+ keyboardActions: KeyboardActions = KeyboardActions.Default,
+ decimalFormatSymbols: DecimalFormatSymbols = DecimalFormat().decimalFormatSymbols,
+ numberOfDecimals: Int = DEFAULT_INPUT_DECIMALS,
+) {
+ var amountInput by remember { mutableStateOf(value) }
+
+ // React to external changes
+ val amountValue = remember(amountInput, value) {
+ transformOutput(amountInput).let {
+ if (value != it) transformInput(value, numberOfDecimals) else amountInput
+ }
+ }
+
+ OutlinedTextField(
+ value = amountValue,
+ onValueChange = { input ->
+ if (input.matches("0+".toRegex())) {
+ amountInput = "0"
+ onValueChange("")
+ } else transformOutput(input, numberOfDecimals)?.let { filtered ->
+ if (Amount.isValidAmountStr(filtered) && !input.contains("-")) {
+ amountInput = input.trimStart('0')
+ onValueChange(filtered)
+ }
+ }
+ },
+ modifier = modifier,
+ textStyle = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+ label = label,
+ supportingText = supportingText,
+ isError = isError,
+ visualTransformation = AmountInputVisualTransformation(
+ symbols = decimalFormatSymbols,
+ fixedCursorAtTheEnd = true,
+ numberOfDecimals = numberOfDecimals,
+ ),
+ keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.NumberPassword),
+ keyboardActions = keyboardActions,
+ singleLine = true,
+ maxLines = 1,
+ )
+}
+
+// 500 -> 5.0
+private fun transformOutput(
+ input: String,
+ numberOfDecimals: Int = 2,
+) = if (input.isEmpty()) "0" else {
+ input.toLongOrNull()?.let { it / 10.0.pow(numberOfDecimals) }?.toBigDecimal()?.toPlainString()
+}
+
+// 5.0 -> 500
+private fun transformInput(
+ output: String,
+ numberOfDecimals: Int = 2,
+) = if (output.isEmpty()) "0" else {
+ (output.toDouble() * 10.0.pow(numberOfDecimals)).roundToLong().toString()
+}
+
+// Source: https://github.com/banmarkovic/CurrencyAmountInput
+
+private class AmountInputVisualTransformation(
+ private val symbols: DecimalFormatSymbols,
+ private val fixedCursorAtTheEnd: Boolean = true,
+ private val numberOfDecimals: Int = 2,
+): VisualTransformation {
+
+ override fun filter(text: AnnotatedString): TransformedText {
+ val thousandsSeparator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ symbols.monetaryGroupingSeparator
+ } else {
+ symbols.groupingSeparator
+ }
+ val decimalSeparator = symbols.monetaryDecimalSeparator
+ val zero = symbols.zeroDigit
+
+ val inputText = text.text
+
+ val intPart = inputText
+ .dropLast(numberOfDecimals)
+ .reversed()
+ .chunked(3)
+ .joinToString(thousandsSeparator.toString())
+ .reversed()
+ .ifEmpty {
+ zero.toString()
+ }
+
+ val fractionPart = inputText.takeLast(numberOfDecimals).let {
+ if (it.length != numberOfDecimals) {
+ List(numberOfDecimals - it.length) {
+ zero
+ }.joinToString("") + it
+ } else {
+ it
+ }
+ }
+
+ // Hide trailing decimal separator if decimals are 0
+ val formattedNumber = if (numberOfDecimals > 0) {
+ intPart + decimalSeparator + fractionPart
+ } else {
+ intPart
+ }
+
+ val newText = AnnotatedString(
+ text = formattedNumber,
+ spanStyles = text.spanStyles,
+ paragraphStyles = text.paragraphStyles
+ )
+
+ val offsetMapping = if (fixedCursorAtTheEnd) {
+ FixedCursorOffsetMapping(
+ contentLength = inputText.length,
+ formattedContentLength = formattedNumber.length
+ )
+ } else {
+ MovableCursorOffsetMapping(
+ unmaskedText = text.toString(),
+ maskedText = newText.toString(),
+ decimalDigits = numberOfDecimals
+ )
+ }
+
+ return TransformedText(newText, offsetMapping)
+ }
+
+ private class FixedCursorOffsetMapping(
+ private val contentLength: Int,
+ private val formattedContentLength: Int,
+ ) : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int = formattedContentLength
+ override fun transformedToOriginal(offset: Int): Int = contentLength
+ }
+
+ private class MovableCursorOffsetMapping(
+ private val unmaskedText: String,
+ private val maskedText: String,
+ private val decimalDigits: Int
+ ) : OffsetMapping {
+ override fun originalToTransformed(offset: Int): Int =
+ when {
+ unmaskedText.length <= decimalDigits -> {
+ maskedText.length - (unmaskedText.length - offset)
+ }
+ else -> {
+ offset + offsetMaskCount(offset, maskedText)
+ }
+ }
+
+ override fun transformedToOriginal(offset: Int): Int =
+ when {
+ unmaskedText.length <= decimalDigits -> {
+ max(unmaskedText.length - (maskedText.length - offset), 0)
+ }
+ else -> {
+ offset - maskedText.take(offset).count { !it.isDigit() }
+ }
+ }
+
+ private fun offsetMaskCount(offset: Int, maskedText: String): Int {
+ var maskOffsetCount = 0
+ var dataCount = 0
+ for (maskChar in maskedText) {
+ if (!maskChar.isDigit()) {
+ maskOffsetCount++
+ } else if (++dataCount > offset) {
+ break
+ }
+ }
+ return maskOffsetCount
+ }
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt
new file mode 100644
index 0000000..6412d63
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/LoadingScreen.kt
@@ -0,0 +1,34 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2024 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+
+@Composable
+fun LoadingScreen() {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center,
+ ) {
+ CircularProgressIndicator()
+ }
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt
new file mode 100644
index 0000000..47401cf
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/NumericInputField.kt
@@ -0,0 +1,70 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.foundation.layout.Row
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Remove
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+@Composable
+fun NumericInputField(
+ modifier: Modifier = Modifier,
+ value: Long,
+ onValueChange: (Long) -> Unit,
+ readOnly: Boolean = true,
+ label: @Composable () -> Unit,
+ minValue: Long? = 0L,
+ maxValue: Long? = null,
+) {
+ OutlinedTextField(
+ modifier = modifier,
+ value = value.toString(),
+ singleLine = true,
+ readOnly = readOnly,
+ onValueChange = {
+ val dd = it.toLongOrNull() ?: 0
+ onValueChange(dd)
+ },
+ trailingIcon = {
+ Row {
+ IconButton(
+ content = { Icon(Icons.Default.Remove, "add1") },
+ onClick = {
+ if (minValue != null && value - 1 >= minValue) {
+ onValueChange(value - 1)
+ }
+ },
+ )
+ IconButton(
+ content = { Icon(Icons.Default.Add, "add1") },
+ onClick = {
+ if (maxValue != null && value + 1 <= maxValue) {
+ onValueChange(value + 1)
+ }
+ },
+ )
+ }
+ },
+ label = label,
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
new file mode 100644
index 0000000..4991094
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/QrCodeUriComposable.kt
@@ -0,0 +1,153 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.horizontalScroll
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.produceState
+import androidx.compose.ui.Alignment.Companion.CenterHorizontally
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.platform.LocalConfiguration
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.min
+import androidx.core.content.getSystemService
+import net.taler.common.QrCodeManager
+import net.taler.wallet.R
+
+@Composable
+fun ColumnScope.QrCodeUriComposable(
+ talerUri: String,
+ clipBoardLabel: String,
+ buttonText: String = stringResource(R.string.copy),
+ inBetween: (@Composable ColumnScope.() -> Unit)? = null,
+) {
+ val qrCodeSize = getQrCodeSize()
+ val qrPlaceHolder = if (LocalInspectionMode.current) {
+ QrCodeManager.makeQrCode(talerUri, qrCodeSize.value.toInt()).asImageBitmap()
+ } else null
+ val qrState = produceState(qrPlaceHolder) {
+ value = QrCodeManager.makeQrCode(talerUri, qrCodeSize.value.toInt()).asImageBitmap()
+ }
+ qrState.value?.let { qrCode ->
+ Image(
+ modifier = Modifier
+ .size(qrCodeSize)
+ .align(CenterHorizontally)
+ .padding(vertical = 8.dp),
+ bitmap = qrCode,
+ contentDescription = stringResource(id = R.string.button_scan_qr_code),
+ )
+ }
+ if (inBetween != null) inBetween()
+ val scrollState = rememberScrollState()
+ Box(modifier = Modifier.padding(16.dp)) {
+ Text(
+ modifier = Modifier.horizontalScroll(scrollState),
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodyLarge,
+ text = talerUri,
+ )
+ }
+ Row(
+ modifier = Modifier
+ .padding(horizontal = 16.dp)
+ .fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceEvenly,
+ ) {
+ CopyToClipboardButton(
+ label = clipBoardLabel,
+ content = talerUri,
+ buttonText = buttonText,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+ ShareButton(
+ content = talerUri,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer
+ )
+ )
+ }
+}
+
+@Composable
+fun getQrCodeSize(): Dp {
+ val configuration = LocalConfiguration.current
+ val screenHeight = configuration.screenHeightDp.dp
+ val screenWidth = configuration.screenWidthDp.dp
+ return min(screenHeight, screenWidth)
+}
+
+@Composable
+fun CopyToClipboardButton(
+ label: String,
+ content: String,
+ modifier: Modifier = Modifier,
+ buttonText: String = stringResource(R.string.copy),
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+) {
+ val context = LocalContext.current
+ Button(
+ modifier = modifier,
+ colors = colors,
+ onClick = { copyToClipBoard(context, label, content) },
+ ) {
+ Icon(
+ Icons.Default.ContentCopy,
+ buttonText,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(buttonText)
+ }
+}
+
+fun copyToClipBoard(context: Context, label: String, str: String) {
+ val clipboard = context.getSystemService<ClipboardManager>()
+ val clip = ClipData.newPlainText(label, str)
+ clipboard?.setPrimaryClip(clip)
+}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt
new file mode 100644
index 0000000..c47f55d
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/SelectionChip.kt
@@ -0,0 +1,46 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.SuggestionChip
+import androidx.compose.material3.SuggestionChipDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+@Composable
+fun <T> SelectionChip(
+ label: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ selected: Boolean,
+ value: T,
+ onSelected: (T) -> Unit,
+) {
+ val theme = MaterialTheme.colorScheme
+ SuggestionChip(
+ label = label,
+ modifier = modifier,
+ onClick = {
+ onSelected(value)
+ },
+ colors = SuggestionChipDefaults.suggestionChipColors(
+ containerColor = if (selected) theme.primaryContainer else Color.Transparent,
+ labelColor = if (selected) theme.onPrimaryContainer else theme.onSurface
+ )
+ )
+} \ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt
new file mode 100644
index 0000000..f3a84dd
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/ShareButton.kt
@@ -0,0 +1,67 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2023 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import android.content.Intent
+import android.content.Intent.ACTION_SEND
+import android.content.Intent.EXTRA_TEXT
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonColors
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.ContextCompat.startActivity
+import net.taler.wallet.R
+
+@Composable
+fun ShareButton(
+ content: String,
+ modifier: Modifier = Modifier,
+ buttonText: String = stringResource(R.string.share),
+ colors: ButtonColors = ButtonDefaults.buttonColors(),
+) {
+ val context = LocalContext.current
+ Button(
+ modifier = modifier,
+ colors = colors,
+ onClick = {
+ val sendIntent: Intent = Intent().apply {
+ action = ACTION_SEND
+ putExtra(EXTRA_TEXT, content)
+ type = "text/plain"
+ }
+ val shareIntent = Intent.createChooser(sendIntent, null)
+ startActivity(context, shareIntent, null)
+ },
+ ) {
+ Icon(
+ Icons.Default.Share,
+ buttonText,
+ modifier = Modifier.size(ButtonDefaults.IconSize),
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text(buttonText)
+ }
+}
diff --git a/wallet/src/main/java/net/taler/wallet/compose/Utils.kt b/wallet/src/main/java/net/taler/wallet/compose/Utils.kt
new file mode 100644
index 0000000..9a27431
--- /dev/null
+++ b/wallet/src/main/java/net/taler/wallet/compose/Utils.kt
@@ -0,0 +1,64 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2022 Taler Systems S.A.
+ *
+ * GNU Taler is free software; you can redistribute it and/or modify it under the
+ * terms of the GNU General Public License as published by the Free Software
+ * Foundation; either version 3, or (at your option) any later version.
+ *
+ * GNU Taler is distributed in the hope that it will be useful, but WITHOUT ANY
+ * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
+ * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License along with
+ * GNU Taler; see the file COPYING. If not, see <http://www.gnu.org/licenses/>
+ */
+
+package net.taler.wallet.compose
+
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.State
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.flowWithLifecycle
+import com.google.accompanist.themeadapter.material3.Mdc3Theme
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.StateFlow
+import kotlin.coroutines.CoroutineContext
+import kotlin.coroutines.EmptyCoroutineContext
+
+@Composable
+fun <T> rememberFlow(
+ flow: Flow<T>,
+ lifecycleOwner: LifecycleOwner = LocalLifecycleOwner.current,
+): Flow<T> = remember(key1 = flow, key2 = lifecycleOwner) {
+ flow.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
+}
+
+@Composable
+fun <T : R, R> Flow<T>.collectAsStateLifecycleAware(
+ initial: R,
+ context: CoroutineContext = EmptyCoroutineContext,
+): State<R> {
+ val lifecycleAwareFlow = rememberFlow(flow = this)
+ return lifecycleAwareFlow.collectAsState(initial = initial, context = context)
+}
+
+@Suppress("StateFlowValueCalledInComposition")
+@Composable
+fun <T> StateFlow<T>.collectAsStateLifecycleAware(
+ context: CoroutineContext = EmptyCoroutineContext,
+): State<T> = collectAsStateLifecycleAware(initial = value, context = context)
+
+@Composable
+fun TalerSurface(content: @Composable () -> Unit) {
+ Mdc3Theme {
+ Surface {
+ content()
+ }
+ }
+}