AmountScopeField.kt (12598B)
1 /* 2 * This file is part of GNU Taler 3 * (C) 2024 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.wallet.compose 18 19 import androidx.compose.animation.AnimatedVisibility 20 import androidx.compose.foundation.clickable 21 import androidx.compose.foundation.interaction.MutableInteractionSource 22 import androidx.compose.foundation.layout.Arrangement 23 import androidx.compose.foundation.layout.Box 24 import androidx.compose.foundation.layout.Column 25 import androidx.compose.foundation.layout.FlowRow 26 import androidx.compose.foundation.layout.fillMaxWidth 27 import androidx.compose.foundation.layout.height 28 import androidx.compose.foundation.layout.padding 29 import androidx.compose.foundation.text.BasicTextField 30 import androidx.compose.foundation.text.KeyboardActions 31 import androidx.compose.foundation.text.KeyboardOptions 32 import androidx.compose.material.icons.Icons 33 import androidx.compose.material.icons.filled.Check 34 import androidx.compose.material3.DropdownMenuItem 35 import androidx.compose.material3.ExperimentalMaterial3Api 36 import androidx.compose.material3.ExposedDropdownMenuBox 37 import androidx.compose.material3.ExposedDropdownMenuDefaults 38 import androidx.compose.material3.Icon 39 import androidx.compose.material3.MenuDefaults 40 import androidx.compose.material3.OutlinedTextFieldDefaults 41 import androidx.compose.material3.Text 42 import androidx.compose.runtime.Composable 43 import androidx.compose.runtime.getValue 44 import androidx.compose.runtime.mutableIntStateOf 45 import androidx.compose.runtime.mutableStateOf 46 import androidx.compose.runtime.remember 47 import androidx.compose.runtime.setValue 48 import androidx.compose.ui.Modifier 49 import androidx.compose.ui.res.stringResource 50 import androidx.compose.ui.text.TextStyle 51 import androidx.compose.ui.text.input.ImeAction 52 import androidx.compose.ui.text.input.VisualTransformation 53 import androidx.compose.ui.tooling.preview.Preview 54 import androidx.compose.ui.unit.dp 55 import net.taler.common.Amount 56 import net.taler.common.CurrencySpecification 57 import net.taler.wallet.R 58 import net.taler.wallet.balances.ScopeInfo 59 import net.taler.wallet.cleanExchange 60 61 data class AmountScope( 62 val amount: Amount = Amount.zero(scope.currency), 63 val scope: ScopeInfo, 64 // whether fee calculation should be debounced 65 val debounce: Boolean = false, 66 // whether it originated from user input 67 val userInput: Boolean = false, 68 ) 69 70 @Composable 71 fun AmountScopeField( 72 modifier: Modifier = Modifier, 73 amount: AmountScope, 74 editableScope: Boolean = true, 75 scopes: List<ScopeInfo>, 76 onAmountChanged: (amount: AmountScope) -> Unit, 77 label: @Composable (() -> Unit)? = null, 78 supportingText: @Composable (() -> Unit)? = null, 79 isError: Boolean = false, 80 readOnly: Boolean = false, 81 keyboardActions: KeyboardActions = KeyboardActions.Default, 82 keyboardOptions: KeyboardOptions = KeyboardOptions.Default.copy(imeAction = ImeAction.Done), 83 showAmount: Boolean = true, 84 showScope: Boolean = true, 85 showShortcuts: Boolean = false, 86 onShortcutSelected: ((amount: AmountScope) -> Unit)? = null, 87 ) { 88 Column(modifier) { 89 if (showScope) { 90 ScopeDropdown( 91 modifier = Modifier 92 .fillMaxWidth() 93 .padding(bottom = 10.dp), 94 scopes = scopes, 95 onScopeChanged = { scope -> 96 onAmountChanged(amount.copy( 97 scope = scope, 98 amount = Amount.zero(scope.currency), 99 )) 100 }, 101 initialScope = amount.scope, 102 readOnly = readOnly || !editableScope, 103 ) 104 } 105 106 if (showAmount) { 107 AmountInputFieldBase( 108 modifier = Modifier 109 .fillMaxWidth(), 110 amount = amount.amount, 111 onAmountChanged = { 112 onAmountChanged(amount.copy(amount = it)) 113 }, 114 label = label, 115 isError = isError, 116 supportingText = supportingText, 117 readOnly = readOnly, 118 keyboardActions = keyboardActions, 119 keyboardOptions = keyboardOptions, 120 showSymbol = true, 121 ) 122 123 val commonAmounts = amount.amount.spec?.commonAmounts 124 ?.map { it.withSpec(amount.amount.spec) } 125 AnimatedVisibility(showShortcuts && amount.amount.isZero() && commonAmounts != null) { 126 if (commonAmounts != null) { 127 AmountInputShortcuts( 128 modifier = Modifier.padding(top = 10.dp), 129 amounts = commonAmounts, 130 onSelected = { shortcut -> 131 onShortcutSelected?.let { 132 it(amount.copy(amount = shortcut)) 133 } 134 }, 135 ) 136 } 137 } 138 } 139 } 140 } 141 142 @OptIn(ExperimentalMaterial3Api::class) 143 @Composable 144 fun ScopeDropdown( 145 scopes: List<ScopeInfo>, 146 onScopeChanged: (ScopeInfo) -> Unit, 147 modifier: Modifier = Modifier, 148 initialScope: ScopeInfo? = null, 149 readOnly: Boolean = false, 150 ) { 151 val initialIndex = scopes.indexOf(initialScope).let { if (it < 0) 0 else it } 152 var selectedIndex by remember { mutableIntStateOf(initialIndex) } 153 var expanded by remember { mutableStateOf(false) } 154 Box( 155 modifier = modifier, 156 ) { 157 val scope = scopes.getOrNull(selectedIndex) 158 ?: initialScope 159 ?: error("no scope available") 160 161 val value = when (scope) { 162 is ScopeInfo.Global -> scope.currency 163 is ScopeInfo.Exchange -> cleanExchange(scope.url) 164 is ScopeInfo.Auditor -> cleanExchange(scope.url) 165 } 166 167 val colors = OutlinedTextFieldDefaults.colors() 168 val singleLine = true 169 val enabled = false 170 val interactionSource = remember { MutableInteractionSource() } 171 172 ExposedDropdownMenuBox( 173 expanded = expanded, 174 onExpandedChange = { expanded = it }, 175 modifier = Modifier, 176 ) { 177 BasicTextField( 178 value = value, 179 modifier = Modifier 180 .height(45.dp) 181 .clickable { if (!readOnly) expanded = true } 182 .fillMaxWidth(), 183 onValueChange = { }, 184 enabled = enabled, 185 readOnly = true, 186 textStyle = TextStyle(color = colors.focusedTextColor), 187 interactionSource = interactionSource, 188 singleLine = singleLine, 189 decorationBox = 190 @Composable { innerTextField -> 191 OutlinedTextFieldDefaults.DecorationBox( 192 value = value, 193 innerTextField = innerTextField, 194 singleLine = singleLine, 195 enabled = enabled, 196 visualTransformation = VisualTransformation.None, 197 interactionSource = interactionSource, 198 prefix = { 199 Text( 200 modifier = Modifier.padding(end = 6.dp), 201 text = stringResource(R.string.currency_via), 202 ) 203 }, 204 contentPadding = OutlinedTextFieldDefaults.contentPadding( 205 top = 0.dp, 206 bottom = 0.dp, 207 ), 208 colors = ExposedDropdownMenuDefaults.textFieldColors(), 209 trailingIcon = { 210 ExposedDropdownMenuDefaults.TrailingIcon(expanded) 211 } 212 ) 213 }, 214 ) 215 216 ExposedDropdownMenu( 217 expanded = expanded, 218 onDismissRequest = { expanded = false }, 219 containerColor = MenuDefaults.containerColor, 220 shape = MenuDefaults.shape, 221 ) { 222 scopes.forEachIndexed { index, s -> 223 DropdownMenuItem( 224 contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, 225 leadingIcon = { 226 if (selectedIndex == index) { 227 Icon(Icons.Filled.Check, contentDescription = null) 228 } 229 }, 230 text = { 231 Text( 232 text = when (s) { 233 is ScopeInfo.Global -> s.currency 234 is ScopeInfo.Exchange -> stringResource( 235 R.string.currency_url, 236 s.currency, 237 cleanExchange(s.url), 238 ) 239 240 is ScopeInfo.Auditor -> stringResource( 241 R.string.currency_url, 242 s.currency, 243 cleanExchange(s.url), 244 ) 245 } 246 ) 247 }, 248 onClick = { 249 selectedIndex = index 250 onScopeChanged(scopes[index]) 251 expanded = false 252 } 253 ) 254 } 255 } 256 } 257 } 258 } 259 260 @Composable 261 private fun AmountInputShortcuts( 262 modifier: Modifier = Modifier, 263 amounts: List<Amount>, 264 onSelected: (amount: Amount) -> Unit, 265 ) { 266 FlowRow( 267 modifier = modifier 268 .fillMaxWidth(), 269 maxItemsInEachRow = 2, 270 horizontalArrangement = Arrangement.SpaceEvenly, 271 ) { 272 amounts.forEach { 273 SelectionChip ( 274 selected = false, 275 label = { Text(it.toString()) }, 276 value = it, 277 onSelected = onSelected, 278 ) 279 } 280 } 281 } 282 283 @Preview 284 @Composable 285 fun AmountInputFieldPreview() { 286 TalerSurface { 287 var amount by remember { 288 mutableStateOf(AmountScope( 289 amount = Amount.fromJSONString("KUDOS:0").withSpec( 290 CurrencySpecification( 291 name = "Kudos", 292 numFractionalInputDigits = 2, 293 numFractionalNormalDigits = 2, 294 numFractionalTrailingZeroDigits = 2, 295 altUnitNames = mapOf(), 296 commonAmounts = listOf( 297 Amount.fromJSONString("KUDOS:5"), 298 Amount.fromJSONString("KUDOS:10"), 299 Amount.fromJSONString("KUDOS:25"), 300 Amount.fromJSONString("KUDOS:50"), 301 ), 302 ), 303 ), 304 scope = ScopeInfo.Exchange("KUDOS", "https://exchange.demo.taler.net/"), 305 )) 306 } 307 AmountScopeField( 308 amount = amount, 309 editableScope = true, 310 scopes = listOf( 311 ScopeInfo.Global("CHF"), 312 ScopeInfo.Exchange("KUDOS", url = "https://exchange.demo.taler.net/"), 313 ScopeInfo.Auditor("TESTKUDOS", url = "https://auditor.test.taler.net/"), 314 ), 315 onAmountChanged = { amount = it }, 316 label = { Text("Amount to withdraw") }, 317 isError = false, 318 readOnly = false, 319 showShortcuts = true, 320 onShortcutSelected = { amount = it }, 321 ) 322 } 323 }