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 }