Browse Source

Add WalletScreen implementation with UI components for displaying wallet details, including balance, transactions, and current price. Implement pull-to-refresh functionality and error handling. Introduce helper functions for clipboard operations.

Carles Sentis 18 hours ago
parent
commit
dd499f7c7c

+ 339 - 0
app/src/main/java/com/codeskraps/publicpool/presentation/wallet/WalletScreen.kt

@@ -0,0 +1,339 @@
+package com.codeskraps.publicpool.presentation.wallet
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.os.Parcelable
+import android.widget.Toast
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.pulltorefresh.PullToRefreshBox
+import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import cafe.adriel.voyager.core.screen.Screen
+import cafe.adriel.voyager.koin.koinScreenModel
+import com.codeskraps.publicpool.R
+import com.codeskraps.publicpool.domain.model.CryptoPrice
+import com.codeskraps.publicpool.domain.model.WalletInfo
+import com.codeskraps.publicpool.domain.model.WalletTransaction
+import com.codeskraps.publicpool.presentation.common.AppCard
+import com.codeskraps.publicpool.ui.theme.PositiveGreen
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.parcelize.Parcelize
+import java.text.NumberFormat
+import java.time.format.DateTimeFormatter
+import java.util.Currency
+import java.util.Locale
+
+@Parcelize
+data object WalletScreen : Screen, Parcelable {
+    private fun readResolve(): Any = WalletScreen
+
+    @OptIn(ExperimentalMaterial3Api::class)
+    @Composable
+    override fun Content() {
+        val screenModel: WalletScreenModel = koinScreenModel()
+        val state by screenModel.state.collectAsState()
+        val context = LocalContext.current
+
+        // Track page view when screen becomes visible
+        LaunchedEffect(Unit) {
+            screenModel.onScreenVisible()
+        }
+
+        LaunchedEffect(key1 = screenModel.effect) {
+            screenModel.effect.collectLatest { effect ->
+                when (effect) {
+                    is WalletEffect.ShowError -> {
+                        Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
+                    }
+                }
+            }
+        }
+
+        Scaffold(
+            topBar = { TopAppBar(title = { Text(stringResource(R.string.screen_title_wallet_details)) }) }
+        ) { paddingValues ->
+            PullToRefreshBox(
+                isRefreshing = state.isWalletLoading || state.isLoading,
+                onRefresh = { screenModel.handleEvent(WalletEvent.LoadWalletDetails) },
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(
+                        top = paddingValues.calculateTopPadding(),
+                        start = 0.dp,
+                        end = 0.dp,
+                        bottom = 0.dp
+                    )
+            ) {
+                Box(
+                    modifier = Modifier.fillMaxSize()
+                ) {
+                    when {
+                        state.isWalletLoading || state.isLoading -> {
+                            // Combined loading indicator
+                            CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
+                        }
+                        state.walletAddress.isNullOrBlank() -> {
+                            // Use stringResource for message
+                            Text(
+                                text = stringResource(R.string.wallet_error_not_set),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        state.errorMessage != null -> {
+                            // Use stringResource for generic error, keep specific from state
+                            Text(
+                                text = state.errorMessage ?: stringResource(R.string.error_unknown),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                color = MaterialTheme.colorScheme.error,
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        state.walletInfo == null -> {
+                             // Use stringResource for message
+                             Text(
+                                text = stringResource(R.string.wallet_error_load_failed),
+                                modifier = Modifier.align(Alignment.Center).padding(16.dp),
+                                textAlign = TextAlign.Center
+                            )
+                        }
+                        else -> {
+                            // Display Wallet Info and Transactions
+                            WalletDetailsContent(
+                                walletInfo = state.walletInfo!!,
+                                btcPrice = state.btcPrice
+                            )
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun WalletDetailsContent(
+    walletInfo: WalletInfo,
+    btcPrice: CryptoPrice?
+) {
+    val context = LocalContext.current
+    val btcFormat = remember { "%.8f BTC" }
+    val dateFormatter = remember { DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") }
+    val displayCurrency = stringResource(R.string.currency_usd)
+    val currencyFormat = remember {
+        NumberFormat.getCurrencyInstance(Locale.US).apply {
+            currency = Currency.getInstance(displayCurrency)
+            maximumFractionDigits = 2
+            minimumFractionDigits = 2
+        }
+    }
+
+    val finalBalanceFiat = remember(walletInfo.finalBalanceBtc, btcPrice) {
+        btcPrice?.let { walletInfo.finalBalanceBtc * it.price }
+    }
+
+    LazyColumn(
+        modifier = Modifier.fillMaxSize(),
+        contentPadding = PaddingValues(16.dp),
+        verticalArrangement = Arrangement.spacedBy(16.dp)
+    ) {
+        // Current Price Section
+        item {
+            CurrentPriceCard(btcPrice = btcPrice, currencyFormat = currencyFormat)
+        }
+
+        // Balance Section
+        item {
+            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+                BalanceCard(
+                    label = stringResource(R.string.wallet_label_final_balance),
+                    valueBtc = btcFormat.format(Locale.US, walletInfo.finalBalanceBtc),
+                    valueFiat = finalBalanceFiat?.let { currencyFormat.format(it) },
+                    fiatCurrencyLabel = btcPrice?.currency ?: stringResource(R.string.currency_usd),
+                    modifier = Modifier.weight(1f)
+                )
+            }
+        }
+
+        // Totals Section
+        item {
+            Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+                BalanceCard(
+                    label = stringResource(R.string.wallet_label_total_received),
+                    valueBtc = btcFormat.format(Locale.US, walletInfo.totalReceivedBtc),
+                    modifier = Modifier.weight(1f)
+                )
+                BalanceCard(
+                    label = stringResource(R.string.wallet_label_total_sent),
+                    valueBtc = btcFormat.format(Locale.US, walletInfo.totalSentBtc),
+                    modifier = Modifier.weight(1f)
+                )
+            }
+        }
+
+        // Transaction Header
+        item {
+            Text(
+                text = stringResource(R.string.wallet_header_recent_transactions_count, walletInfo.transactionCount),
+                style = MaterialTheme.typography.titleMedium,
+                modifier = Modifier.padding(top = 8.dp) // Add some space before tx list
+            )
+        }
+
+        // Transaction List
+        items(walletInfo.transactions, key = { it.hash }) { tx ->
+            TransactionItem(tx = tx, dateFormatter = dateFormatter)
+        }
+    }
+}
+
+@Composable
+fun CurrentPriceCard(btcPrice: CryptoPrice?, currencyFormat: NumberFormat) {
+    AppCard(modifier = Modifier.fillMaxWidth()) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .defaultMinSize(minHeight = 90.dp)
+                .padding(12.dp),
+            verticalArrangement = Arrangement.SpaceBetween
+        ) {
+            Text(
+                text = stringResource(R.string.wallet_label_current_btc_price),
+                style = MaterialTheme.typography.labelMedium,
+                color = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+
+            Box(modifier = Modifier.align(Alignment.End)) {
+                if (btcPrice != null) {
+                    Text(
+                        text = currencyFormat.format(btcPrice.price),
+                        style = MaterialTheme.typography.headlineSmall,
+                        fontWeight = FontWeight.Bold,
+                        color = MaterialTheme.colorScheme.onSurface
+                    )
+                } else {
+                    Text(
+                        text = stringResource(R.string.text_placeholder_dash),
+                         style = MaterialTheme.typography.headlineSmall,
+                         fontWeight = FontWeight.Bold,
+                         color = MaterialTheme.colorScheme.onSurfaceVariant
+                     )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun BalanceCard(
+    label: String,
+    valueBtc: String,
+    valueFiat: String? = null,
+    fiatCurrencyLabel: String? = null,
+    modifier: Modifier = Modifier
+) {
+    AppCard(modifier = modifier) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .defaultMinSize(minHeight = 90.dp)
+                .padding(12.dp),
+            verticalArrangement = Arrangement.SpaceBetween
+        ) {
+            Text(
+                text = label,
+                style = MaterialTheme.typography.labelMedium,
+                color = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+
+            Box(modifier = Modifier.align(Alignment.End)) {
+                Column(horizontalAlignment = Alignment.End) {
+                    Text(
+                        text = valueBtc,
+                        style = MaterialTheme.typography.bodyLarge,
+                        fontWeight = FontWeight.Bold,
+                        color = MaterialTheme.colorScheme.onSurface
+                    )
+                    Text(
+                        text = valueFiat?.let { "${stringResource(R.string.wallet_balance_fiat_prefix)} $it${fiatCurrencyLabel?.let { c -> " $c" } ?: ""}" } ?: "",
+                        style = MaterialTheme.typography.bodySmall,
+                        color = if (valueFiat != null) PositiveGreen else Color.Transparent
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+fun TransactionItem(tx: WalletTransaction, dateFormatter: DateTimeFormatter) {
+    val btcFormat = remember { "%.8f BTC" }
+    val netValueFormatted = btcFormat.format(Locale.US, tx.resultBtc)
+    val timeFormatted = tx.time?.format(dateFormatter) ?: "Pending"
+    val valueColor = if (tx.resultSatoshis >= 0) Color(0xFF2E7D32) else MaterialTheme.colorScheme.error // Green for positive, Red for negative
+
+    AppCard(modifier = Modifier.fillMaxWidth()) {
+        Column(modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.SpaceBetween,
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                Text(text = timeFormatted, style = MaterialTheme.typography.labelMedium)
+                Text(text = netValueFormatted, style = MaterialTheme.typography.bodyMedium, color = valueColor)
+            }
+            Spacer(modifier = Modifier.height(4.dp))
+            Text(
+                text = tx.hash,
+                style = MaterialTheme.typography.bodySmall,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                color = MaterialTheme.colorScheme.onSurfaceVariant
+            )
+        }
+    }
+}
+
+// Helper function to copy text to clipboard
+fun copyToClipboard(context: Context, text: String) {
+    val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+    // Use context.getString() for non-composable contexts
+    val label = context.getString(R.string.wallet_clipboard_label)
+    val message = context.getString(R.string.wallet_toast_address_copied)
+
+    val clip = ClipData.newPlainText(label, text)
+    clipboard.setPrimaryClip(clip)
+    Toast.makeText(context, message, Toast.LENGTH_SHORT).show()
+}