rename example

This commit is contained in:
2026-06-01 03:09:19 +02:00
parent 7f3d14a764
commit b5473bfafc
59 changed files with 261 additions and 182 deletions
@@ -0,0 +1,433 @@
package com.bitcointxoko.gudariwallet.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.draw.alpha
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Search
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat
import kotlinx.coroutines.delay
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit
) {
val balanceState by vm.balanceState.collectAsState()
val balanceHidden by vm.balanceHidden.collectAsState()
var eyeVisible by remember { mutableStateOf(false) }
LaunchedEffect(balanceHidden) {
eyeVisible = true
delay(1_500)
eyeVisible = false
}
val eyeAlpha by animateFloatAsState(
targetValue = if (eyeVisible) 1f else 0f,
animationSpec = tween(durationMillis = 400),
label = "eyeAlpha"
)
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = { vm.refreshBalance() },
modifier = Modifier.fillMaxSize()
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
when (val s = balanceState) {
is BalanceState.Loading -> {
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = "Loading balance…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
isLoadingCurrencies = true
currencyList = vm.getSupportedCurrencies()
isLoadingCurrencies = false
}
}
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = if (balanceHidden)
Icons.Filled.VisibilityOff
else
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
.alpha(eyeAlpha)
)
Spacer(Modifier.width(8.dp))
Text(
text = if (balanceHidden) "••••••" else "${s.sats}",
style = MaterialTheme.typography.displayLarge,
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.primary,
maxLines = 1,
softWrap = false,
modifier = Modifier
.weight(1f, fill = false)
.clickable(
onClick = { vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) "Reveal balance" else "Hide balance",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Spacer(Modifier.size(20.dp))
}
Text(
text = "sats",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(2.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
) {
if (s.fiatAmount != null && s.fiatCurrency != null) {
Text(
text = if (balanceHidden) "≈ ••••"
else "${formatFiat(s.fiatAmount, s.fiatCurrency)}",
style = MaterialTheme.typography.bodyMedium,
color = if (balanceHidden)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.35f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f),
maxLines = 1,
softWrap = false
)
Spacer(Modifier.width(2.dp))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
.padding(start = 0.dp, end = 4.dp, top = 4.dp, bottom = 4.dp)
) {
Text(
text = selectedCurrency ?: "",
style = MaterialTheme.typography.bodyMedium,
color = if (selectedCurrency != null)
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
else
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
// Triangle only shown when null — signals "tap to set a currency"
if (selectedCurrency == null) {
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Change currency",
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.size(14.dp)
)
}
}
}
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated)
}
Text(
text = "Updated $updatedLabel",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(16.dp))
TextButton(onClick = onHistoryClick) {
Text(
text = "View History",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
selectedCurrency = selectedCurrency,
onSelect = { code ->
vm.setSelectedCurrency(code)
showCurrencyPicker = false
},
isLoading = isLoadingCurrencies,
onDismiss = { showCurrencyPicker = false }
)
}
}
is BalanceState.Error -> {
if (s.staleSats != null) {
Text(
text = if (balanceHidden) "••••••" else "${s.staleSats}",
style = MaterialTheme.typography.displayLarge,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = "sats",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
Spacer(Modifier.height(12.dp))
}
Surface(
color = MaterialTheme.colorScheme.errorContainer,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(0.85f)
) {
Text(
text = if (s.staleSats != null)
"Could not refresh — pull down to try again"
else
"Could not load balance — pull down to try again",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
)
}
}
}
}
}
}
private fun formatLastUpdated(epochMs: Long): String {
val ageMs = System.currentTimeMillis() - epochMs
return when {
ageMs < 60_000L -> "just now"
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
else -> {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun CurrencyPickerSheet(
currencies : List<String>,
isLoading : Boolean, // NEW — true while list is being fetched
selectedCurrency: String?,
onSelect : (String?) -> Unit,
onDismiss : () -> Unit
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
var query by remember { mutableStateOf("") }
val focusManager = LocalFocusManager.current
// Filter list client-side — no network, instant response
// Only active once the list is populated
val filtered = remember(query, currencies) {
if (query.isBlank()) currencies
else currencies.filter { it.contains(query.trim(), ignoreCase = true) }
}
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState
) {
Column(modifier = Modifier.fillMaxWidth()) {
// ── Header ────────────────────────────────────────────────────────
Text(
text = "Select currency",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 8.dp)
)
// ── "None" option — always pinned at top, never filtered ──────────
ListItem(
headlineContent = {
Text(
text = "None",
style = MaterialTheme.typography.bodyLarge,
color = if (selectedCurrency == null)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
},
supportingContent = {
Text(
text = "Hide fiat equivalent",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
},
trailingContent = if (selectedCurrency == null) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}) else null,
modifier = Modifier.clickable { onSelect(null) }
)
HorizontalDivider(thickness = 1.dp)
// ── Search field — hidden while loading ───────────────────────────
if (!isLoading) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search…") },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
},
singleLine = true,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Text,
imeAction = ImeAction.Search
),
keyboardActions = KeyboardActions(
onSearch = { focusManager.clearFocus() }
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp)
)
}
// ── Body — spinner OR list OR empty state ─────────────────────────
when {
isLoading -> {
// Spinner while list is being fetched on first open
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 48.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(28.dp))
}
}
filtered.isEmpty() -> {
// No results after search
Text(
text = if (query.isBlank()) "No currencies available"
else "No currencies match \"$query\"",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
)
}
else -> {
LazyColumn(
modifier = Modifier.fillMaxWidth(),
contentPadding = PaddingValues(bottom = 32.dp)
) {
items(filtered, key = { it }) { code ->
val isSelected = code == selectedCurrency
ListItem(
headlineContent = {
Text(
text = code,
style = MaterialTheme.typography.bodyLarge,
color = if (isSelected)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
},
trailingContent = if (isSelected) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
}) else null,
modifier = Modifier.clickable { onSelect(code) }
)
HorizontalDivider(thickness = 0.5.dp)
}
}
}
}
}
}
}