rename example
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user