ExchangeListScreen.kt (11566B)
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.wallet.exchanges 18 19 import androidx.compose.foundation.layout.Box 20 import androidx.compose.foundation.layout.Column 21 import androidx.compose.foundation.layout.Row 22 import androidx.compose.foundation.layout.fillMaxSize 23 import androidx.compose.foundation.layout.fillMaxWidth 24 import androidx.compose.foundation.layout.navigationBarsPadding 25 import androidx.compose.foundation.layout.padding 26 import androidx.compose.foundation.lazy.LazyColumn 27 import androidx.compose.foundation.lazy.items 28 import androidx.compose.material.icons.Icons 29 import androidx.compose.material.icons.filled.AccountBalance 30 import androidx.compose.material.icons.filled.Add 31 import androidx.compose.material.icons.filled.MoreVert 32 import androidx.compose.material3.AlertDialog 33 import androidx.compose.material3.DropdownMenu 34 import androidx.compose.material3.DropdownMenuItem 35 import androidx.compose.material3.ExperimentalMaterial3Api 36 import androidx.compose.material3.FloatingActionButton 37 import androidx.compose.material3.HorizontalDivider 38 import androidx.compose.material3.Icon 39 import androidx.compose.material3.IconButton 40 import androidx.compose.material3.MaterialTheme 41 import androidx.compose.material3.OutlinedTextField 42 import androidx.compose.material3.Text 43 import androidx.compose.material3.TextButton 44 import androidx.compose.runtime.Composable 45 import androidx.compose.runtime.LaunchedEffect 46 import androidx.compose.runtime.getValue 47 import androidx.compose.runtime.livedata.observeAsState 48 import androidx.compose.runtime.mutableStateOf 49 import androidx.compose.runtime.remember 50 import androidx.compose.runtime.rememberCoroutineScope 51 import androidx.compose.runtime.setValue 52 import androidx.compose.ui.Alignment 53 import androidx.compose.ui.Modifier 54 import androidx.compose.ui.focus.FocusRequester 55 import androidx.compose.ui.focus.focusRequester 56 import androidx.compose.ui.res.stringResource 57 import androidx.compose.ui.unit.dp 58 import kotlinx.coroutines.CoroutineScope 59 import kotlinx.coroutines.launch 60 import net.taler.wallet.NavigateCallback 61 import net.taler.wallet.R 62 import net.taler.wallet.WalletDestination 63 import net.taler.wallet.balances.BalanceManager 64 import net.taler.wallet.balances.ScopeInfo 65 import net.taler.wallet.compose.EmptyComposable 66 import net.taler.wallet.compose.GlobalScaffold 67 import net.taler.wallet.main.MainViewModel 68 69 @OptIn(ExperimentalMaterial3Api::class) 70 @Composable 71 fun ExchangeListScreen( 72 model: MainViewModel, 73 onNavigate: NavigateCallback, 74 onNavigateBack: () -> Unit, 75 ) { 76 val exchangeManager = model.exchangeManager 77 val balanceManager = model.balanceManager 78 val exchanges by exchangeManager.exchanges.observeAsState(emptyList()) 79 val devMode by model.devMode.observeAsState(false) 80 val scope = rememberCoroutineScope() 81 var showAddDialog by remember { mutableStateOf(false) } 82 83 GlobalScaffold( 84 model = model, 85 modifier = Modifier.fillMaxSize(), 86 onNavigateBack = onNavigateBack, 87 title = { Text(stringResource(R.string.exchange_list_title)) }, 88 floatingActionButton = { 89 FloatingActionButton( 90 modifier = Modifier.navigationBarsPadding(), 91 onClick = { showAddDialog = true }, 92 ) { 93 Icon(Icons.Default.Add, contentDescription = stringResource(R.string.exchange_list_add)) 94 } 95 } 96 ) { padding -> 97 if (exchanges.isEmpty()) { 98 EmptyComposable( 99 modifier = Modifier.fillMaxSize().padding(padding), 100 message = stringResource(R.string.exchange_list_empty), 101 ) 102 } else { 103 LazyColumn(modifier = Modifier.fillMaxSize().padding(padding)) { 104 items(exchanges) { exchange -> 105 ExchangeItemComposable( 106 exchange = exchange, 107 devMode = devMode, 108 onAction = { action -> 109 handleExchangeAction( 110 scope = scope, 111 exchangeManager = exchangeManager, 112 balanceManager = balanceManager, 113 exchange = exchange, 114 action = action, 115 onNavigate = onNavigate, 116 ) 117 } 118 ) 119 HorizontalDivider() 120 } 121 } 122 } 123 } 124 125 if (showAddDialog) { 126 AddExchangeDialog( 127 onDismiss = { showAddDialog = false }, 128 onConfirm = { url -> 129 exchangeManager.add(url) 130 showAddDialog = false 131 } 132 ) 133 } 134 } 135 136 @Composable 137 fun ExchangeItemComposable( 138 exchange: ExchangeItem, 139 devMode: Boolean, 140 onAction: (ExchangeAction) -> Unit, 141 ) { 142 var showMenu by remember { mutableStateOf(false) } 143 144 Row( 145 modifier = Modifier 146 .fillMaxWidth() 147 .padding(16.dp), 148 verticalAlignment = Alignment.CenterVertically 149 ) { 150 Column(modifier = Modifier.weight(1f)) { 151 Text(text = exchange.name, style = MaterialTheme.typography.titleMedium) 152 val currencyText = exchange.currency?.let { 153 stringResource(R.string.exchange_list_currency, it) 154 } ?: stringResource(R.string.exchange_not_contacted) 155 Text(text = currencyText, style = MaterialTheme.typography.bodyMedium) 156 } 157 if (exchange.currency != null) { 158 Box { 159 IconButton(onClick = { showMenu = true }) { 160 Icon(Icons.Default.MoreVert, contentDescription = null) 161 } 162 ExchangeDropDownMenu( 163 expanded = showMenu, 164 onDismissRequest = { showMenu = false }, 165 exchange = exchange, 166 devMode = devMode, 167 onAction = { 168 showMenu = false 169 onAction(it) 170 } 171 ) 172 } 173 } 174 } 175 } 176 177 @Composable 178 fun ExchangeDropDownMenu( 179 expanded: Boolean, 180 onDismissRequest: () -> Unit, 181 exchange: ExchangeItem, 182 devMode: Boolean, 183 onAction: (ExchangeAction) -> Unit, 184 ) { 185 DropdownMenu(expanded = expanded, onDismissRequest = onDismissRequest) { 186 DropdownMenuItem( 187 text = { Text(stringResource(R.string.exchange_reload)) }, 188 onClick = { onAction(ExchangeAction.Reload) } 189 ) 190 191 if (exchange.tosStatus.isAccepted()) { 192 DropdownMenuItem( 193 text = { Text(stringResource(R.string.exchange_tos_view)) }, 194 onClick = { onAction(ExchangeAction.ViewTos) } 195 ) 196 if (devMode) { 197 DropdownMenuItem( 198 text = { Text(stringResource(R.string.exchange_tos_forget)) }, 199 onClick = { onAction(ExchangeAction.ForgetTos) } 200 ) 201 } 202 } else { 203 DropdownMenuItem( 204 text = { Text(stringResource(R.string.exchange_tos_accept)) }, 205 onClick = { onAction(ExchangeAction.AcceptTos) } 206 ) 207 } 208 209 if (devMode) { 210 if (exchange.scopeInfo is ScopeInfo.Exchange) { 211 DropdownMenuItem( 212 text = { Text(stringResource(R.string.exchange_global_add)) }, 213 onClick = { onAction(ExchangeAction.GlobalAdd) } 214 ) 215 } else if (exchange.scopeInfo is ScopeInfo.Global) { 216 DropdownMenuItem( 217 text = { Text(stringResource(R.string.exchange_global_delete)) }, 218 onClick = { onAction(ExchangeAction.GlobalDelete) } 219 ) 220 } 221 } 222 223 DropdownMenuItem( 224 text = { Text(stringResource(R.string.exchange_delete)) }, 225 onClick = { onAction(ExchangeAction.Delete) } 226 ) 227 } 228 } 229 230 @Composable 231 fun AddExchangeDialog( 232 onDismiss: () -> Unit, 233 onConfirm: (String) -> Unit, 234 ) { 235 val focusRequester = remember { FocusRequester() } 236 var url by remember { mutableStateOf("") } 237 AlertDialog( 238 modifier = Modifier.focusRequester(focusRequester), 239 onDismissRequest = onDismiss, 240 title = { Text(stringResource(R.string.exchange_list_add)) }, 241 icon = { Icon(Icons.Default.AccountBalance, contentDescription = null) }, 242 text = { 243 OutlinedTextField( 244 modifier = Modifier.fillMaxWidth(), 245 value = url, 246 onValueChange = { url = it }, 247 label = { Text(stringResource(R.string.exchange_add_url)) }, 248 placeholder = { Text("https://") }, 249 maxLines = 1, 250 ) 251 }, 252 confirmButton = { 253 TextButton(onClick = { onConfirm(url) }) { 254 Text(stringResource(R.string.ok)) 255 } 256 }, 257 dismissButton = { 258 TextButton(onClick = onDismiss) { 259 Text(stringResource(R.string.cancel)) 260 } 261 } 262 ) 263 264 LaunchedEffect(Unit) { 265 focusRequester.requestFocus() 266 } 267 } 268 269 enum class ExchangeAction { 270 Reload, 271 ViewTos, 272 AcceptTos, 273 ForgetTos, 274 GlobalAdd, 275 GlobalDelete, 276 Delete 277 } 278 279 fun handleExchangeAction( 280 scope: CoroutineScope, 281 exchangeManager: ExchangeManager, 282 balanceManager: BalanceManager, 283 exchange: ExchangeItem, 284 action: ExchangeAction, 285 onNavigate: NavigateCallback, 286 ) { 287 when (action) { 288 ExchangeAction.Reload -> exchangeManager.reload(exchange.exchangeBaseUrl) 289 ExchangeAction.ViewTos -> { 290 onNavigate(WalletDestination.ReviewExchangeTOS(exchange.exchangeBaseUrl, true), false) 291 } 292 ExchangeAction.AcceptTos -> { 293 onNavigate(WalletDestination.ReviewExchangeTOS(exchange.exchangeBaseUrl, false), false) 294 } 295 ExchangeAction.ForgetTos -> { 296 scope.launch { 297 exchangeManager.getExchangeTos(exchange.exchangeBaseUrl)?.let { tos -> 298 exchangeManager.forgetCurrentTos(exchange.exchangeBaseUrl, tos.currentEtag) 299 } 300 } 301 } 302 ExchangeAction.GlobalAdd -> { 303 balanceManager.addGlobalCurrencyExchange( 304 currency = exchange.currency ?: return, 305 exchange = exchange, 306 onSuccess = {}, 307 onError = {} 308 ) 309 } 310 ExchangeAction.GlobalDelete -> { 311 balanceManager.removeGlobalCurrencyExchange( 312 currency = exchange.currency ?: return, 313 exchange = exchange, 314 onSuccess = {}, 315 onError = {} 316 ) 317 } 318 ExchangeAction.Delete -> exchangeManager.delete(exchange.exchangeBaseUrl) 319 } 320 }