commit b1cc9e96782f07b3b5c75aebe75048ce1c981f97
parent 95de9ff11af35a5b2e324ad606a8d56b335b14c3
Author: Iván Ávalos <avalos@disroot.org>
Date: Sat, 16 May 2026 16:57:21 +0200
[wallet] initial implementation of tokens UI
Diffstat:
8 files changed, 640 insertions(+), 0 deletions(-)
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt b/wallet/src/main/java/net/taler/wallet/WalletNavHost.kt
@@ -53,6 +53,8 @@ import net.taler.wallet.peer.IncomingPushPaymentScreen
import net.taler.wallet.peer.OutgoingPullScreen
import net.taler.wallet.peer.OutgoingPushScreen
import net.taler.wallet.settings.PerformanceStatsScreen
+import net.taler.wallet.tokens.TokenListScreen
+import net.taler.wallet.tokens.TokenViewMode
import net.taler.wallet.transactions.TransactionDetailScreen
import net.taler.wallet.transfer.WireTransferDetailsScreen
import net.taler.wallet.withdraw.PromptWithdrawScreen
@@ -252,6 +254,22 @@ fun WalletNavHost(
onShowError = { onShowError(it) }
)
}
+ composable<WalletDestination.DiscountList> {
+ TokenListScreen(
+ model = model,
+ onNavigate = onNavigate,
+ onNavigateBack = onNavigateBack,
+ viewMode = TokenViewMode.Discounts,
+ )
+ }
+ composable<WalletDestination.PassList> {
+ TokenListScreen(
+ model = model,
+ onNavigate = onNavigate,
+ onNavigateBack = onNavigateBack,
+ viewMode = TokenViewMode.Passes,
+ )
+ }
composable<WalletDestination.SetDonau> { backStackEntry ->
val dest = backStackEntry.toRoute<WalletDestination.SetDonau>()
SetDonauScreen(
diff --git a/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt b/wallet/src/main/java/net/taler/wallet/WalletNavigation.kt
@@ -51,6 +51,12 @@ sealed interface WalletDestination {
data class AddBankAccount(val bankAccountId: String? = null) : WalletDestination
@Serializable
+ data object DiscountList : WalletDestination
+
+ @Serializable
+ data object PassList : WalletDestination
+
+ @Serializable
data class DonauStatement(val host: String) : WalletDestination
@Serializable
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt b/wallet/src/main/java/net/taler/wallet/main/MainScreen.kt
@@ -291,6 +291,12 @@ fun MainScreen(
onStatementClicked = {
onNavigate(WalletDestination.DonauStatement(it), false)
},
+ onShowDiscounts = {
+ onNavigate(WalletDestination.DiscountList, false)
+ },
+ onShowPasses = {
+ onNavigate(WalletDestination.PassList, false)
+ },
)
is ViewMode.Transactions -> {
@@ -416,6 +422,7 @@ fun UriInputDialog(
val isValidTalerUri = { uri: String ->
uri.trim().startsWith("taler://", ignoreCase = true) ||
+ uri.trim().startsWith("taler+http://", ignoreCase = true) ||
uri.trim().startsWith("payto://", ignoreCase = true)
}
diff --git a/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt b/wallet/src/main/java/net/taler/wallet/main/MainViewModel.kt
@@ -58,6 +58,7 @@ import net.taler.wallet.withdraw.WithdrawManager
import net.taler.wallet.BuildConfig
import net.taler.wallet.NetworkManager
import net.taler.wallet.donau.DonauManager
+import net.taler.wallet.tokens.TokenManager
const val TAG = "taler-wallet"
const val OBSERVABILITY_LIMIT = 100
@@ -113,6 +114,7 @@ class MainViewModel(
val settingsManager: SettingsManager = SettingsManager(app.applicationContext, api, viewModelScope, balanceManager)
val accountManager: AccountManager = AccountManager(api, viewModelScope)
val depositManager: DepositManager = DepositManager(api, viewModelScope, balanceManager)
+ val tokenManager: TokenManager = TokenManager(api)
val donauManager: DonauManager = DonauManager(api, viewModelScope, exchangeManager)
private val mAuthenticated = MutableStateFlow(false)
diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenListScreen.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenListScreen.kt
@@ -0,0 +1,428 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 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.tokens
+
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
+import androidx.compose.material3.Badge
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.ListItem
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedCard
+import androidx.compose.material3.PrimaryTabRow
+import androidx.compose.material3.Tab
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.launch
+import net.taler.common.RelativeTime
+import net.taler.common.TalerUtils
+import net.taler.common.Timestamp
+import net.taler.lib.android.toAbsoluteTime
+import net.taler.wallet.NavigateCallback
+import net.taler.wallet.R
+import net.taler.wallet.cleanExchange
+import net.taler.wallet.compose.EmptyComposable
+import net.taler.wallet.compose.ErrorComposable
+import net.taler.wallet.compose.GlobalScaffold
+import net.taler.wallet.compose.TalerSurface
+import net.taler.wallet.compose.cardPaddings
+import net.taler.wallet.compose.collectAsStateLifecycleAware
+import net.taler.wallet.main.MainViewModel
+import net.taler.wallet.ui.theme.TalerTheme
+
+enum class TokenViewMode {
+ Discounts,
+ Passes,
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TokenListScreen(
+ model: MainViewModel,
+ viewMode: TokenViewMode,
+ onNavigate: NavigateCallback,
+ onNavigateBack: () -> Unit,
+) {
+ val pages = listOf(TokenFilter.Valid, TokenFilter.Expired)
+ val pagerState = rememberPagerState { pages.size }
+
+ val scope = rememberCoroutineScope()
+ val devMode by model.devMode.observeAsState(false)
+ val discounts by model.tokenManager.discounts.collectAsStateLifecycleAware(UiState.Loading)
+ val passes by model.tokenManager.passes.collectAsStateLifecycleAware(UiState.Loading)
+
+ GlobalScaffold(
+ model = model,
+ modifier = Modifier.fillMaxSize(),
+ onNavigateBack = onNavigateBack,
+ title = {
+ Text(when (viewMode) {
+ TokenViewMode.Discounts -> stringResource(R.string.discounts_title)
+ TokenViewMode.Passes -> stringResource(R.string.passes_title)
+ })
+ },
+ tabs = {
+ PrimaryTabRow(
+ selectedTabIndex = pagerState.currentPage,
+ ) {
+ pages.forEachIndexed { index, filter ->
+ Tab(
+ selected = pagerState.currentPage == index,
+ onClick = { scope.launch { pagerState.scrollToPage(index) } },
+ text = {
+ Text(
+ when (filter) {
+ TokenFilter.Valid -> stringResource(R.string.tokens_filter_available)
+ TokenFilter.Expired -> stringResource(R.string.tokens_filter_expired)
+ }
+ )
+ },
+ )
+ }
+ }
+ }
+ ) { paddingValues ->
+ HorizontalPager(
+ state = pagerState,
+ modifier = Modifier
+ .padding(paddingValues)
+ .fillMaxSize(),
+ verticalAlignment = Alignment.Top,
+ ) { page ->
+ val filter = pages[page]
+ when (viewMode) {
+ TokenViewMode.Discounts -> DiscountList(discounts, filter, devMode)
+ TokenViewMode.Passes -> PassList(passes, filter, devMode)
+ }
+ }
+ }
+}
+
+@Composable
+fun DiscountList(uiState: UiState<List<DiscountListDetail>>, filter: TokenFilter, devMode: Boolean) {
+ TokenListContent(uiState, devMode) { discounts ->
+ val filtered = discounts.filterDiscounts(filter)
+ if (filtered.isEmpty()) {
+ EmptyComposable(
+ message = when (filter) {
+ is TokenFilter.Valid -> stringResource(R.string.passes_empty_valid)
+ is TokenFilter.Expired -> stringResource(R.string.passes_empty_expired)
+ },
+ )
+ }
+ LazyColumn(Modifier.fillMaxSize()) {
+ items(filtered) { discount ->
+ DiscountCard(discount)
+ }
+ }
+ }
+}
+
+@Composable
+fun PassList(uiState: UiState<List<SubscriptionListDetail>>, filter: TokenFilter, devMode: Boolean) {
+ TokenListContent(uiState, devMode) { passes ->
+ val filtered = passes.filterPasses(filter)
+ if (filtered.isEmpty()) {
+ EmptyComposable(
+ message = when (filter) {
+ is TokenFilter.Valid -> stringResource(R.string.passes_empty_valid)
+ is TokenFilter.Expired -> stringResource(R.string.passes_empty_expired)
+ },
+ )
+ }
+ LazyColumn(Modifier.fillMaxSize()) {
+ items(filtered) { pass ->
+ PassCard(pass)
+ }
+ }
+ }
+}
+
+@Composable
+fun <T> TokenListContent(
+ uiState: UiState<T>,
+ devMode: Boolean,
+ content: @Composable (T) -> Unit
+) {
+ Box(Modifier.fillMaxSize()) {
+ when (uiState) {
+ is UiState.Loading -> CircularProgressIndicator(Modifier.align(Alignment.Center))
+ is UiState.Error -> ErrorComposable(error = uiState.error, devMode = devMode)
+ is UiState.Success -> content(uiState.data)
+ }
+ }
+}
+
+@Composable
+fun DiscountCard(pass: DiscountListDetail) {
+ val isActive = remember(pass) { pass.isActive }
+ OutlinedCard(Modifier.cardPaddings()) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(pass.name, style = MaterialTheme.typography.headlineMedium
+ .copy(fontWeight = FontWeight.Medium))
+ },
+ supportingContent = {
+ Column {
+ Text(
+ TalerUtils.getLocalizedString(
+ pass.descriptionI18n,
+ pass.description,
+ ),
+ style= MaterialTheme.typography.labelLarge,
+ )
+
+ Text(
+ // TODO: expose merchantName in wallet-core
+ stringResource(
+ R.string.pass_issuer,
+ cleanExchange(pass.merchantBaseUrl)
+ ),
+ modifier = Modifier.padding(top = 5.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ trailingContent = {
+ Badge(
+ containerColor = if (isActive) {
+ MaterialTheme.colorScheme.primary
+ } else {
+ MaterialTheme.colorScheme.surfaceVariant
+ },
+ contentColor = if (isActive) {
+ MaterialTheme.colorScheme.onPrimary
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant
+ },
+ ) {
+ Text(
+ text = stringResource(R.string.discount_quantity, pass.tokensAvailable),
+ modifier = Modifier.padding(3.5.dp),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+ )
+
+ TokenValidityFooter(isActive,
+ pass.validityStart,
+ pass.validityEnd)
+ }
+ }
+}
+
+@Composable
+fun PassCard(pass: SubscriptionListDetail) {
+ val isActive = remember(pass) { pass.isActive }
+ OutlinedCard(Modifier.cardPaddings()) {
+ Column {
+ ListItem(
+ headlineContent = {
+ Text(pass.name, style = MaterialTheme.typography.headlineMedium
+ .copy(fontWeight = FontWeight.Medium))
+ },
+ supportingContent = {
+ Column {
+ Text(
+ TalerUtils.getLocalizedString(
+ pass.descriptionI18n,
+ pass.description,
+ ),
+ style = MaterialTheme.typography.labelLarge,
+ )
+
+ Text(
+ // TODO: expose merchantName in wallet-core
+ stringResource(
+ R.string.pass_issuer,
+ cleanExchange(pass.merchantBaseUrl)
+ ),
+ modifier = Modifier.padding(top = 5.dp),
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ },
+ trailingContent = {
+ if (isActive) {
+ Badge(
+ containerColor = MaterialTheme.colorScheme.primary,
+ contentColor = MaterialTheme.colorScheme.onPrimary,
+ ) {
+ Text(
+ text = stringResource(R.string.pass_active),
+ modifier = Modifier.padding(3.5.dp),
+ style = MaterialTheme.typography.labelLarge,
+ )
+ }
+ }
+ }
+ )
+
+ TokenValidityFooter(isActive,
+ pass.validityStart,
+ pass.validityEnd)
+ }
+ }
+}
+
+@Composable
+fun TokenValidityFooter(
+ isActive: Boolean,
+ validityStart: Timestamp,
+ validityEnd: Timestamp,
+) {
+ val context = LocalContext.current
+
+ HorizontalDivider()
+
+ Box(modifier = Modifier
+ .background(if (isActive) {
+ MaterialTheme.colorScheme.secondaryContainer
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerLow
+ })
+ .fillMaxWidth()
+ .padding(vertical = 12.dp)
+ .padding(horizontal = 6.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ if (isActive) {
+ Text(
+ text = stringResource(
+ R.string.token_valid_until,
+ validityEnd.ms.toAbsoluteTime(context),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSecondaryContainer,
+ )
+ } else {
+ Text(
+ text = stringResource(
+ R.string.token_valid_from,
+ validityStart.ms.toAbsoluteTime(context),
+ ),
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ )
+ }
+ }
+}
+
+private val oneWeek = RelativeTime.fromMillis(86400000 * 7)
+
+@Preview
+@Composable
+fun DiscountListPreview() {
+ val now = Timestamp.now()
+ TalerSurface {
+ TalerTheme {
+ DiscountList(
+ uiState = UiState.Success(
+ data = listOf(
+ DiscountListDetail(
+ tokenFamilyHash = "",
+ tokenIssuePubHash = "",
+ merchantBaseUrl = "https://backend.demo.taler.net/",
+ name = "20% off",
+ description = "valid for fruits",
+ descriptionI18n = mapOf(),
+ validityStart = now - oneWeek,
+ validityEnd = now + oneWeek,
+ tokensAvailable = 3,
+ ),
+ DiscountListDetail(
+ tokenFamilyHash = "",
+ tokenIssuePubHash = "",
+ merchantBaseUrl = "https://backend.demo.taler.net/",
+ name = "Name",
+ description = "Description",
+ descriptionI18n = mapOf(),
+ validityStart = now + oneWeek,
+ validityEnd = now + oneWeek + oneWeek,
+ tokensAvailable = 2,
+ ),
+ ),
+ ),
+ filter = TokenFilter.Valid,
+ devMode = true,
+ )
+ }
+ }
+}
+
+@Preview
+@Composable
+fun PassListPreview() {
+ val now = Timestamp.now()
+ TalerSurface {
+ TalerTheme {
+ PassList(
+ uiState = UiState.Success(
+ data = listOf(
+ SubscriptionListDetail(
+ tokenFamilyHash = "",
+ tokenIssuePubHash = "",
+ merchantBaseUrl = "https://backend.demo.taler.net/",
+ name = "Premium pass",
+ description = "Provides you access to this mega description",
+ descriptionI18n = mapOf(),
+ validityStart = now - oneWeek,
+ validityEnd = now + oneWeek,
+ ),
+
+ SubscriptionListDetail(
+ tokenFamilyHash = "",
+ tokenIssuePubHash = "",
+ merchantBaseUrl = "https://backend.demo.taler.net/",
+ name = "Name",
+ description = "Description",
+ descriptionI18n = mapOf(),
+ validityStart = now + oneWeek,
+ validityEnd = now + oneWeek + oneWeek,
+ ),
+ ),
+ ),
+ filter = TokenFilter.Valid,
+ devMode = true,
+ )
+ }
+ }
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenManager.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenManager.kt
@@ -0,0 +1,83 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 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.tokens
+
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.flow
+import net.taler.common.Timestamp
+import net.taler.wallet.backend.TalerErrorInfo
+import net.taler.wallet.backend.WalletBackendApi
+import net.taler.wallet.backend.WalletResponse
+
+sealed class UiState<out T> {
+ object Loading: UiState<Nothing>()
+ data class Success<out T>(val data: T): UiState<T>()
+ data class Error(val error: TalerErrorInfo): UiState<Nothing>()
+}
+
+fun <T, U> WalletResponse<T>.toUiState(transform: (r: T) -> U): UiState<U> = when(this) {
+ is WalletResponse.Success -> UiState.Success(transform(this.result))
+ is WalletResponse.Error -> UiState.Error(this.error)
+}
+
+sealed class TokenFilter() {
+ // data object All: TokenFilter()
+ data object Valid: TokenFilter()
+ data object Expired: TokenFilter()
+}
+
+class TokenManager(private val api: WalletBackendApi){
+ private val refreshTrigger =
+ MutableSharedFlow<Unit>(replay = 1).apply { tryEmit(Unit) }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val discounts: Flow<UiState<List<DiscountListDetail>>> = refreshTrigger.flatMapLatest {
+ flow {
+ emit(UiState.Loading)
+ emit(api.request("listDiscounts", ListDiscountsResponse.serializer())
+ .toUiState { it.discounts })
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val passes: Flow<UiState<List<SubscriptionListDetail>>> = refreshTrigger.flatMapLatest {
+ flow {
+ emit(UiState.Loading)
+ emit(api.request("listSubscriptions", ListSubscriptionsResponse.serializer())
+ .toUiState { it.subscriptions })
+ }
+ }
+
+ fun refresh() { refreshTrigger.tryEmit(Unit) }
+}
+
+fun List<DiscountListDetail>.filterDiscounts(filter: TokenFilter): List<DiscountListDetail> {
+ return when(filter) {
+ TokenFilter.Valid -> filter { !it.isExpired }
+ TokenFilter.Expired -> filter { it.isExpired }
+ }
+}
+
+fun List<SubscriptionListDetail>.filterPasses(filter: TokenFilter): List<SubscriptionListDetail> {
+ return when(filter) {
+ TokenFilter.Valid -> filter { !it.isExpired }
+ TokenFilter.Expired -> filter { it.isExpired }
+ }
+}
+\ No newline at end of file
diff --git a/wallet/src/main/java/net/taler/wallet/tokens/TokenResponses.kt b/wallet/src/main/java/net/taler/wallet/tokens/TokenResponses.kt
@@ -0,0 +1,75 @@
+/*
+ * This file is part of GNU Taler
+ * (C) 2026 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.tokens
+
+import kotlinx.serialization.Serializable
+import net.taler.common.Timestamp
+
+@Serializable
+data class DiscountListDetail(
+ val tokenFamilyHash: String,
+ val tokenIssuePubHash: String,
+ val merchantBaseUrl: String,
+ val name: String,
+ val description: String,
+ val descriptionI18n: Map<String, String>,
+ val validityStart: Timestamp,
+ val validityEnd: Timestamp,
+ val tokensAvailable: Int,
+) {
+ val isActive: Boolean get() {
+ val now = Timestamp.now()
+ return now in validityStart..validityEnd
+ }
+
+ val isExpired: Boolean get() {
+ val now = Timestamp.now()
+ return now >= validityEnd
+ }
+}
+
+@Serializable
+data class ListDiscountsResponse(
+ val discounts: List<DiscountListDetail>,
+)
+
+@Serializable
+data class SubscriptionListDetail(
+ val tokenFamilyHash: String,
+ val tokenIssuePubHash: String,
+ val merchantBaseUrl: String,
+ val name: String,
+ val description: String,
+ val descriptionI18n: Map<String, String>,
+ val validityStart: Timestamp,
+ val validityEnd: Timestamp,
+) {
+ val isActive: Boolean get() {
+ val now = Timestamp.now()
+ return now in validityStart..validityEnd
+ }
+
+ val isExpired: Boolean get() {
+ val now = Timestamp.now()
+ return now >= validityEnd
+ }
+}
+
+@Serializable
+data class ListSubscriptionsResponse(
+ val subscriptions: List<SubscriptionListDetail>,
+)
+\ No newline at end of file
diff --git a/wallet/src/main/res/values/strings.xml b/wallet/src/main/res/values/strings.xml
@@ -142,6 +142,7 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<!-- must be minus sign! (− U+2212) -->
<string name="balances_outbound_amount">−%1$s outgoing</string>
<string name="balances_section_statements">Donation statements</string>
+ <string name="balances_section_tokens">Other assets</string>
<string name="balances_title">Balances</string>
<!-- Transactions -->
@@ -205,6 +206,23 @@ GNU Taler is immune to many types of fraud such as credit card data theft, phish
<string name="transactions_suspend">Suspend</string>
<string name="transactions_title">Transactions</string>
+ <!-- Discounts/passes -->
+ <string name="discount_issuer">Redeemable at %1$s</string>
+ <!-- must be a × (U+2A09) times character -->
+ <string name="discount_quantity">× %1$d</string>
+ <string name="discounts_empty_expired">You don\'t have any expired discounts</string>
+ <string name="discounts_empty_valid">You don\'t have any valid discounts</string>
+ <string name="discounts_title">Discounts</string>
+ <string name="pass_active">Active</string>
+ <string name="pass_issuer">Provided by %1$s</string>
+ <string name="passes_empty_expired">You don\'t have any expired passes</string>
+ <string name="passes_empty_valid">You don\'t have any valid passes</string>
+ <string name="passes_title">Passes</string>
+ <string name="token_valid_from">Valid from %1$s</string>
+ <string name="tokens_filter_available">Available</string>
+ <string name="tokens_filter_expired">Expired</string>
+ <string name="token_valid_until">Valid until %1$s</string>
+
<!-- Donau -->
<string name="donau_id">Donor tax payer ID</string>