taler-android

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

TokenListScreen.kt (16288B)


      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.tokens
     18 
     19 import androidx.compose.foundation.background
     20 import androidx.compose.foundation.layout.Box
     21 import androidx.compose.foundation.layout.Column
     22 import androidx.compose.foundation.layout.fillMaxSize
     23 import androidx.compose.foundation.layout.fillMaxWidth
     24 import androidx.compose.foundation.layout.padding
     25 import androidx.compose.foundation.lazy.LazyColumn
     26 import androidx.compose.foundation.lazy.items
     27 import androidx.compose.foundation.pager.HorizontalPager
     28 import androidx.compose.foundation.pager.rememberPagerState
     29 import androidx.compose.material3.Badge
     30 import androidx.compose.material3.CircularProgressIndicator
     31 import androidx.compose.material3.ExperimentalMaterial3Api
     32 import androidx.compose.material3.HorizontalDivider
     33 import androidx.compose.material3.ListItem
     34 import androidx.compose.material3.MaterialTheme
     35 import androidx.compose.material3.OutlinedCard
     36 import androidx.compose.material3.PrimaryTabRow
     37 import androidx.compose.material3.Tab
     38 import androidx.compose.material3.Text
     39 import androidx.compose.runtime.Composable
     40 import androidx.compose.runtime.getValue
     41 import androidx.compose.runtime.livedata.observeAsState
     42 import androidx.compose.runtime.remember
     43 import androidx.compose.runtime.rememberCoroutineScope
     44 import androidx.compose.ui.Alignment
     45 import androidx.compose.ui.Modifier
     46 import androidx.compose.ui.platform.LocalContext
     47 import androidx.compose.ui.res.stringResource
     48 import androidx.compose.ui.text.font.FontWeight
     49 import androidx.compose.ui.tooling.preview.Preview
     50 import androidx.compose.ui.unit.dp
     51 import kotlinx.coroutines.launch
     52 import net.taler.common.Merchant
     53 import net.taler.common.RelativeTime
     54 import net.taler.common.TalerUtils
     55 import net.taler.common.Timestamp
     56 import net.taler.lib.android.toAbsoluteTime
     57 import net.taler.wallet.NavigateCallback
     58 import net.taler.wallet.R
     59 import net.taler.wallet.cleanExchange
     60 import net.taler.wallet.compose.EmptyComposable
     61 import net.taler.wallet.compose.ErrorComposable
     62 import net.taler.wallet.compose.GlobalScaffold
     63 import net.taler.wallet.compose.TalerSurface
     64 import net.taler.wallet.compose.cardPaddings
     65 import net.taler.wallet.compose.collectAsStateLifecycleAware
     66 import net.taler.wallet.main.MainViewModel
     67 import net.taler.wallet.ui.theme.TalerTheme
     68 
     69 enum class TokenViewMode {
     70     Discounts,
     71     Passes,
     72 }
     73 
     74 @OptIn(ExperimentalMaterial3Api::class)
     75 @Composable
     76 fun TokenListScreen(
     77     model: MainViewModel,
     78     viewMode: TokenViewMode,
     79     onNavigate: NavigateCallback,
     80     onNavigateBack: () -> Unit,
     81 ) {
     82     val pages = listOf(TokenFilter.Valid, TokenFilter.Expired)
     83     val pagerState = rememberPagerState { pages.size }
     84 
     85     val scope = rememberCoroutineScope()
     86     val devMode by model.devMode.observeAsState(false)
     87     val discounts by model.tokenManager.discounts.collectAsStateLifecycleAware(UiState.Loading)
     88     val passes by model.tokenManager.passes.collectAsStateLifecycleAware(UiState.Loading)
     89 
     90     GlobalScaffold(
     91         model = model,
     92         modifier = Modifier.fillMaxSize(),
     93         onNavigateBack = onNavigateBack,
     94         title = {
     95             Text(when (viewMode) {
     96                 TokenViewMode.Discounts -> stringResource(R.string.discounts_title)
     97                 TokenViewMode.Passes -> stringResource(R.string.passes_title)
     98             })
     99         },
    100         tabs = {
    101             PrimaryTabRow(
    102                 selectedTabIndex = pagerState.currentPage,
    103             ) {
    104                 pages.forEachIndexed { index, filter ->
    105                     Tab(
    106                         selected = pagerState.currentPage == index,
    107                         onClick = { scope.launch { pagerState.scrollToPage(index) } },
    108                         text = {
    109                             Text(
    110                                 when (filter) {
    111                                     TokenFilter.Valid -> stringResource(R.string.tokens_filter_available)
    112                                     TokenFilter.Expired -> stringResource(R.string.tokens_filter_expired)
    113                                 }
    114                             )
    115                         },
    116                     )
    117                 }
    118             }
    119         }
    120     ) { paddingValues ->
    121         HorizontalPager(
    122             state = pagerState,
    123             modifier = Modifier
    124                 .padding(paddingValues)
    125                 .fillMaxSize(),
    126             verticalAlignment = Alignment.Top,
    127         ) { page ->
    128             val filter = pages[page]
    129             when (viewMode) {
    130                 TokenViewMode.Discounts -> DiscountList(discounts, filter, devMode)
    131                 TokenViewMode.Passes -> PassList(passes, filter, devMode)
    132             }
    133         }
    134     }
    135 }
    136 
    137 @Composable
    138 fun DiscountList(uiState: UiState<List<DiscountListDetail>>, filter: TokenFilter, devMode: Boolean) {
    139     TokenListContent(uiState, devMode) { discounts ->
    140         val filtered = discounts.filterDiscounts(filter)
    141         if (filtered.isEmpty()) {
    142             EmptyComposable(
    143                 message = when (filter) {
    144                     is TokenFilter.Valid -> stringResource(R.string.discounts_empty_valid)
    145                     is TokenFilter.Expired -> stringResource(R.string.discounts_empty_expired)
    146                 },
    147             )
    148         }
    149         LazyColumn(Modifier.fillMaxSize()) {
    150             items(filtered) { discount ->
    151                 DiscountCard(discount)
    152             }
    153         }
    154     }
    155 }
    156 
    157 @Composable
    158 fun PassList(uiState: UiState<List<SubscriptionListDetail>>, filter: TokenFilter, devMode: Boolean) {
    159     TokenListContent(uiState, devMode) { passes ->
    160         val filtered = passes.filterPasses(filter)
    161         if (filtered.isEmpty()) {
    162             EmptyComposable(
    163                 message = when (filter) {
    164                     is TokenFilter.Valid -> stringResource(R.string.passes_empty_valid)
    165                     is TokenFilter.Expired -> stringResource(R.string.passes_empty_expired)
    166                 },
    167             )
    168         }
    169         LazyColumn(Modifier.fillMaxSize()) {
    170             items(filtered) { pass ->
    171                 PassCard(pass)
    172             }
    173         }
    174     }
    175 }
    176 
    177 @Composable
    178 fun <T> TokenListContent(
    179     uiState: UiState<T>,
    180     devMode: Boolean,
    181     content: @Composable (T) -> Unit
    182 ) {
    183     Box(Modifier.fillMaxSize()) {
    184         when (uiState) {
    185             is UiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
    186             is UiState.Error -> ErrorComposable(error = uiState.error, devMode = devMode)
    187             is UiState.Success -> content(uiState.data)
    188         }
    189     }
    190 }
    191 
    192 @Composable
    193 fun DiscountCard(pass: DiscountListDetail) {
    194     val isActive = remember(pass) { pass.isActive }
    195     OutlinedCard(Modifier.cardPaddings()) {
    196         Column {
    197             ListItem(
    198                 // leadingContent = { MerchantAvatar(pass.merchantInfo, size = 40.dp) },
    199                 headlineContent = {
    200                     Text(pass.name, style = MaterialTheme.typography.headlineMedium
    201                         .copy(fontWeight = FontWeight.Medium))
    202                 },
    203                 supportingContent = {
    204                     Column {
    205                         Text(
    206                             TalerUtils.getLocalizedString(
    207                                 pass.descriptionI18n,
    208                                 pass.description,
    209                             ),
    210                             style= MaterialTheme.typography.labelLarge,
    211                         )
    212 
    213                         Text(
    214                             stringResource(
    215                                 R.string.pass_issuer,
    216                                 pass.merchantInfo?.name
    217                                     ?: cleanExchange(pass.merchantBaseUrl),
    218                             ),
    219                             modifier = Modifier.padding(top = 5.dp),
    220                             style = MaterialTheme.typography.bodySmall,
    221                         )
    222                     }
    223                 },
    224                 trailingContent = {
    225                     Badge(
    226                         containerColor = if (isActive) {
    227                             MaterialTheme.colorScheme.primary
    228                         } else {
    229                             MaterialTheme.colorScheme.surfaceVariant
    230                         },
    231                         contentColor = if (isActive) {
    232                             MaterialTheme.colorScheme.onPrimary
    233                         } else {
    234                             MaterialTheme.colorScheme.onSurfaceVariant
    235                         },
    236                     ) {
    237                         Text(
    238                             text = stringResource(R.string.discount_quantity, pass.tokensAvailable),
    239                             modifier = Modifier.padding(3.5.dp),
    240                             style = MaterialTheme.typography.labelLarge,
    241                         )
    242                     }
    243                 }
    244             )
    245 
    246             TokenValidityFooter(isActive,
    247                 pass.validityStart,
    248                 pass.validityEnd)
    249         }
    250     }
    251 }
    252 
    253 @Composable
    254 fun PassCard(pass: SubscriptionListDetail) {
    255     val isActive = remember(pass) { pass.isActive }
    256     OutlinedCard(Modifier.cardPaddings()) {
    257         Column {
    258             ListItem(
    259                 // leadingContent = { MerchantAvatar(pass.merchantInfo, size = 40.dp) },
    260                 headlineContent = {
    261                     Text(pass.name, style = MaterialTheme.typography.headlineMedium
    262                         .copy(fontWeight = FontWeight.Medium))
    263                 },
    264                 supportingContent = {
    265                     Column {
    266                         Text(
    267                             TalerUtils.getLocalizedString(
    268                                 pass.descriptionI18n,
    269                                 pass.description,
    270                             ),
    271                             style = MaterialTheme.typography.labelLarge,
    272                         )
    273 
    274                         Text(
    275                             stringResource(
    276                                 R.string.pass_issuer,
    277                                 pass.merchantInfo?.name
    278                                     ?: cleanExchange(pass.merchantBaseUrl),
    279                             ),
    280                             modifier = Modifier.padding(top = 5.dp),
    281                             style = MaterialTheme.typography.bodySmall,
    282                         )
    283                     }
    284                 },
    285                 trailingContent = {
    286                     if (isActive) {
    287                         Badge(
    288                             containerColor = MaterialTheme.colorScheme.primary,
    289                             contentColor = MaterialTheme.colorScheme.onPrimary,
    290                         ) {
    291                             Text(
    292                                 text = stringResource(R.string.pass_active),
    293                                 modifier = Modifier.padding(3.5.dp),
    294                                 style = MaterialTheme.typography.labelLarge,
    295                             )
    296                         }
    297                     }
    298                 }
    299             )
    300 
    301             TokenValidityFooter(isActive,
    302                 pass.validityStart,
    303                 pass.validityEnd)
    304         }
    305     }
    306 }
    307 
    308 @Composable
    309 fun TokenValidityFooter(
    310     isActive: Boolean,
    311     validityStart: Timestamp,
    312     validityEnd: Timestamp,
    313 ) {
    314     val context = LocalContext.current
    315 
    316     HorizontalDivider()
    317 
    318     Box(modifier = Modifier
    319         .background(if (isActive) {
    320             MaterialTheme.colorScheme.secondaryContainer
    321         } else {
    322             MaterialTheme.colorScheme.surfaceContainerLow
    323         })
    324         .fillMaxWidth()
    325         .padding(vertical = 12.dp)
    326         .padding(horizontal = 6.dp),
    327         contentAlignment = Alignment.Center,
    328     ) {
    329         if (isActive) {
    330             Text(
    331                 text = stringResource(
    332                     R.string.token_valid_until,
    333                     validityEnd.ms.toAbsoluteTime(context),
    334                 ),
    335                 style = MaterialTheme.typography.labelMedium,
    336                 color = MaterialTheme.colorScheme.onSecondaryContainer,
    337             )
    338         } else {
    339             Text(
    340                 text = stringResource(
    341                     R.string.token_valid_from,
    342                     validityStart.ms.toAbsoluteTime(context),
    343                 ),
    344                 style = MaterialTheme.typography.labelMedium,
    345                 color = MaterialTheme.colorScheme.onSurface,
    346             )
    347         }
    348     }
    349 }
    350 
    351 private val oneWeek = RelativeTime.fromMillis(86400000 * 7)
    352 
    353 @Preview
    354 @Composable
    355 fun DiscountListPreview() {
    356     val now = Timestamp.now()
    357     TalerSurface {
    358         TalerTheme {
    359             DiscountList(
    360                 uiState = UiState.Success(
    361                     data = listOf(
    362                         DiscountListDetail(
    363                             tokenFamilyHash = "",
    364                             tokenIssuePubHash = "",
    365                             merchantBaseUrl = "https://backend.demo.taler.net/",
    366                             merchantInfo = Merchant(name = "Test Merchant"),
    367                             name = "20% off",
    368                             description = "valid for fruits",
    369                             descriptionI18n = mapOf(),
    370                             validityStart = now - oneWeek,
    371                             validityEnd = now + oneWeek,
    372                             tokensAvailable = 3,
    373                         ),
    374                         DiscountListDetail(
    375                             tokenFamilyHash = "",
    376                             tokenIssuePubHash = "",
    377                             merchantBaseUrl = "https://backend.demo.taler.net/",
    378                             name = "Name",
    379                             description = "Description",
    380                             descriptionI18n = mapOf(),
    381                             validityStart = now + oneWeek,
    382                             validityEnd = now + oneWeek + oneWeek,
    383                             tokensAvailable = 2,
    384                         ),
    385                     ),
    386                 ),
    387                 filter = TokenFilter.Valid,
    388                 devMode = true,
    389             )
    390         }
    391     }
    392 }
    393 
    394 @Preview
    395 @Composable
    396 fun PassListPreview() {
    397     val now = Timestamp.now()
    398     TalerSurface {
    399         TalerTheme {
    400             PassList(
    401                 uiState = UiState.Success(
    402                     data = listOf(
    403                         SubscriptionListDetail(
    404                             tokenFamilyHash = "",
    405                             tokenIssuePubHash = "",
    406                             merchantBaseUrl = "https://backend.demo.taler.net/",
    407                             merchantInfo = Merchant(name = "Test Merchant"),
    408                             name = "Premium pass",
    409                             description = "Provides you access to this mega description",
    410                             descriptionI18n = mapOf(),
    411                             validityStart = now - oneWeek,
    412                             validityEnd = now + oneWeek,
    413                         ),
    414 
    415                         SubscriptionListDetail(
    416                             tokenFamilyHash = "",
    417                             tokenIssuePubHash = "",
    418                             merchantBaseUrl = "https://backend.demo.taler.net/",
    419                             name = "Name",
    420                             description = "Description",
    421                             descriptionI18n = mapOf(),
    422                             validityStart = now + oneWeek,
    423                             validityEnd = now + oneWeek + oneWeek,
    424                         ),
    425                     ),
    426                 ),
    427                 filter = TokenFilter.Valid,
    428                 devMode = true,
    429             )
    430         }
    431     }
    432 }