diff options
Diffstat (limited to 'wallet/src/main/java/net/taler/wallet/compose')
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() + } + } +} |