feat: localisation with lyricist

This commit is contained in:
2026-06-05 23:52:30 +02:00
parent 039f1c430a
commit ed3371aa6f
12 changed files with 453 additions and 243 deletions
@@ -21,6 +21,7 @@ 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.material.icons.filled.Language // ← NEW
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
@@ -28,19 +29,25 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.platform.LocalFocusManager
import com.bitcointxoko.gudariwallet.util.formatFiat
import androidx.compose.ui.text.font.FontFamily
import com.bitcointxoko.gudariwallet.LocalAppStrings
import cafe.adriel.lyricist.Lyricist // ← NEW
import com.bitcointxoko.gudariwallet.i18n.AppStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeTab(
vm: WalletViewModel,
onHistoryClick: () -> Unit
onHistoryClick: () -> Unit,
lyricist: Lyricist<AppStrings> // ← NEW: passed in from wherever you call ProvideStrings
) {
val balanceState by vm.balanceState.collectAsState()
val strings = LocalAppStrings.current
val balanceState by vm.balanceState.collectAsState()
val balanceHidden by vm.balanceHidden.collectAsState()
// ── NEW: language switcher state ──────────────────────────────────────────
val supportedTags = remember { listOf("en", "es") } // add more as you add locale files
val isRefreshing = balanceState is BalanceState.Loading
|| (balanceState as? BalanceState.Success)?.isRefreshing == true
@@ -63,7 +70,7 @@ fun HomeTab(
CircularProgressIndicator()
Spacer(Modifier.height(12.dp))
Text(
text = "Loading balance…",
text = strings.loadingBalance,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -72,7 +79,7 @@ fun HomeTab(
is BalanceState.Success -> {
val selectedCurrency by vm.selectedCurrency.collectAsState()
var showCurrencyPicker by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var isLoadingCurrencies by remember { mutableStateOf(false) }
var currencyList by remember { mutableStateOf<List<String>>(emptyList()) }
LaunchedEffect(showCurrencyPicker) {
if (showCurrencyPicker && currencyList.isEmpty()) {
@@ -82,7 +89,7 @@ fun HomeTab(
}
}
Row(
verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (balanceHidden)
@@ -91,8 +98,7 @@ fun HomeTab(
Icons.Filled.Visibility,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.size(20.dp)
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
@@ -112,16 +118,16 @@ fun HomeTab(
.widthIn(max = 280.dp)
.combinedClickable(
onClick = { if (!balanceHidden) vm.toggleBalanceVisibility() },
onClickLabel = if (balanceHidden) null else "Hide balance",
onClickLabel = if (balanceHidden) null else strings.hideBalance,
onLongClick = { if (balanceHidden) vm.toggleBalanceVisibility() },
onLongClickLabel = if (balanceHidden) "Reveal balance" else null,
onLongClickLabel = if (balanceHidden) strings.revealBalance else null,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
)
Spacer(Modifier.width(8.dp))
Text(
text = "sats",
text = strings.sats,
style = MaterialTheme.typography.titleMedium.copy(
fontFamily = FontFamily.Monospace
),
@@ -132,7 +138,6 @@ fun HomeTab(
Spacer(Modifier.height(2.dp))
val currency = selectedCurrency
if (currency != null) {
// ── Fiat amount + tappable currency code ──────────────────────────
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center
@@ -153,13 +158,12 @@ fun HomeTab(
)
Spacer(Modifier.width(4.dp))
}
// Currency code — always shown when a currency is selected, tappable to change
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
onClick = { showCurrencyPicker = true },
onClickLabel = "Change currency",
onClickLabel = strings.changeCurrency,
indication = null,
interactionSource = remember { MutableInteractionSource() }
)
@@ -175,35 +179,33 @@ fun HomeTab(
Spacer(Modifier.width(2.dp))
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = "Change currency",
contentDescription = strings.changeCurrency,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
modifier = Modifier.size(14.dp)
)
}
}
} else {
// ── No currency selected — ghost prompt ───────────────────────────
TextButton(
onClick = { showCurrencyPicker = true },
onClick = { showCurrencyPicker = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Text(
text = "+ Show fiat",
text = strings.showFiat,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.45f)
)
}
}
Spacer(Modifier.height(8.dp))
if (!s.isRefreshing) {
val updatedLabel: String = remember(s.lastUpdated) {
formatLastUpdated(s.lastUpdated)
formatLastUpdated(s.lastUpdated, strings)
}
Text(
text = "Updated $updatedLabel",
text = strings.updatedAt(updatedLabel),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -213,11 +215,21 @@ fun HomeTab(
TextButton(onClick = onHistoryClick) {
Text(
text = "View History",
text = strings.viewHistory,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary
)
}
// ── NEW: Language switcher ─────────────────────────────────
Spacer(Modifier.height(8.dp))
LanguageSwitcherButton(
currentTag = lyricist.languageTag,
supportedTags = supportedTags,
onSelect = { tag -> lyricist.languageTag = tag }
)
// ──────────────────────────────────────────────────────────
if (showCurrencyPicker) {
CurrencyPickerSheet(
currencies = currencyList,
@@ -242,7 +254,7 @@ fun HomeTab(
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
)
Text(
text = "sats",
text = strings.sats,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
@@ -256,9 +268,9 @@ fun HomeTab(
) {
Text(
text = if (s.staleSats != null)
"Could not refresh — pull down to try again"
strings.couldNotRefresh
else
"Could not load balance — pull down to try again",
strings.couldNotLoadBalance,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.padding(12.dp)
@@ -270,19 +282,99 @@ fun HomeTab(
}
}
// ── NEW: Language switcher composable ─────────────────────────────────────────
@Composable
private fun LanguageSwitcherButton(
currentTag : String,
supportedTags: List<String>,
onSelect : (String) -> Unit
) {
var expanded by remember { mutableStateOf(false) }
// Human-readable display names for each tag
val displayName: (String) -> String = { tag ->
when (tag) {
"en" -> "English"
"es" -> "Español"
else -> tag.uppercase()
}
}
Box {
TextButton(
onClick = { expanded = true },
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 2.dp)
) {
Icon(
imageVector = Icons.Filled.Language,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Spacer(Modifier.width(4.dp))
Text(
text = displayName(currentTag),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
Icon(
imageVector = Icons.Filled.ArrowDropDown,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
supportedTags.forEach { tag ->
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = displayName(tag),
style = MaterialTheme.typography.bodyMedium,
color = if (tag == currentTag)
MaterialTheme.colorScheme.primary
else
MaterialTheme.colorScheme.onSurface
)
}
},
trailingIcon = if (tag == currentTag) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp)
)
}) else null,
onClick = {
onSelect(tag)
expanded = false
}
)
}
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
private fun formatSats(sats: Long): String =
"%,d".format(sats).replace(',', ' ')
private fun formatLastUpdated(epochMs: Long): String {
private fun formatLastUpdated(epochMs: Long, strings: AppStrings): String {
val ageMs = System.currentTimeMillis() - epochMs
return when {
ageMs < 60_000L -> "just now"
ageMs < 3_600_000L -> "${ageMs / 60_000L} min ago"
ageMs < 60_000L -> strings.justNow
ageMs < 3_600_000L -> strings.minAgo((ageMs / 60_000L).toInt())
else -> {
val formatter = java.time.format.DateTimeFormatter
.ofPattern("HH:mm")
.withZone(java.time.ZoneId.systemDefault())
"at ${formatter.format(java.time.Instant.ofEpochMilli(epochMs))}"
strings.atTime(formatter.format(java.time.Instant.ofEpochMilli(epochMs)))
}
}
}
@@ -291,17 +383,16 @@ private fun formatLastUpdated(epochMs: Long): String {
@Composable
private fun CurrencyPickerSheet(
currencies : List<String>,
isLoading : Boolean, // NEW — true while list is being fetched
isLoading : Boolean,
selectedCurrency: String?,
onSelect : (String?) -> Unit,
onDismiss : () -> Unit
) {
val strings = LocalAppStrings.current
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) }
@@ -313,18 +404,16 @@ private fun CurrencyPickerSheet(
) {
Column(modifier = Modifier.fillMaxWidth()) {
// ── Header ────────────────────────────────────────────────────────
Text(
text = "Select currency",
text = strings.selectCurrency,
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",
text = strings.currencyNone,
style = MaterialTheme.typography.bodyLarge,
color = if (selectedCurrency == null)
MaterialTheme.colorScheme.primary
@@ -334,7 +423,7 @@ private fun CurrencyPickerSheet(
},
supportingContent = {
Text(
text = "Hide fiat equivalent",
text = strings.hideFiatEquivalent,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
@@ -342,7 +431,7 @@ private fun CurrencyPickerSheet(
trailingContent = if (selectedCurrency == null) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = strings.selected,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
@@ -351,12 +440,11 @@ private fun CurrencyPickerSheet(
)
HorizontalDivider(thickness = 1.dp)
// ── Search field — hidden while loading ───────────────────────────
if (!isLoading) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
placeholder = { Text("Search…") },
placeholder = { Text(strings.search) },
leadingIcon = {
Icon(
imageVector = Icons.Filled.Search,
@@ -378,10 +466,8 @@ private fun CurrencyPickerSheet(
)
}
// ── Body — spinner OR list OR empty state ─────────────────────────
when {
isLoading -> {
// Spinner while list is being fetched on first open
Box(
modifier = Modifier
.fillMaxWidth()
@@ -393,10 +479,9 @@ private fun CurrencyPickerSheet(
}
filtered.isEmpty() -> {
// No results after search
Text(
text = if (query.isBlank()) "No currencies available"
else "No currencies match \"$query\"",
text = if (query.isBlank()) strings.noCurrenciesAvailable
else strings.noCurrenciesMatch(query),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 24.dp, vertical = 16.dp)
@@ -424,7 +509,7 @@ private fun CurrencyPickerSheet(
trailingContent = if (isSelected) ({
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Selected",
contentDescription = strings.selected,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(18.dp)
)
@@ -439,4 +524,3 @@ private fun CurrencyPickerSheet(
}
}
}