taler-android

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

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 }