Files
gudari/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt
T

495 lines
23 KiB
Kotlin

package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager
import android.util.Log
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Send
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentPaste
import androidx.compose.material.icons.filled.Nfc
import androidx.compose.material.icons.filled.QrCode
import androidx.compose.material.icons.filled.QrCodeScanner
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.NavType
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.bitcointxoko.gudariwallet.MainActivity
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModel
import com.bitcointxoko.gudariwallet.ui.history.HistoryViewModelFactory
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
import com.bitcointxoko.gudariwallet.ui.nfc.NfcViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
import com.bitcointxoko.gudariwallet.util.SendInputType
import kotlinx.coroutines.delay
// ── Tab destinations ──────────────────────────────────────────────────────────
sealed class TabItem(val route: String, val label: String, val icon: ImageVector) {
data object Receive : TabItem("receive", "Receive", Icons.Filled.QrCode)
data object Send : TabItem("send", "Send", Icons.AutoMirrored.Filled.Send)
}
private const val ROUTE_HOME = "home"
private const val ROUTE_SCANNER = "scanner"
private const val ROUTE_HISTORY = "history"
private const val ROUTE_PAYMENT_DETAIL = "payment_detail/{checkingId}"
@Composable
fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel, // ← new
pendingPaymentHash: MutableState<String?>,
pendingPaymentUri : MutableState<String?>
) {
val navController = rememberNavController()
val context = LocalContext.current
val historyFactory = remember {
HistoryViewModelFactory(
repo = vm.repo,
aliasRepo = vm.aliasRepo,
paymentCache = vm.paymentCache,
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
LaunchedEffect(hash) {
if (hash != null) {
navController.navigate("payment_detail/$hash") {
launchSingleTop = true
}
pendingPaymentHash.value = null
}
}
val activity = context as MainActivity
LaunchedEffect(Unit) {
activity.windowFocusEvents.collect {
val cm = context.getSystemService(ClipboardManager::class.java)
val text = cm?.primaryClip?.getItemAt(0)
?.coerceToText(context)?.toString()
vm.checkClipboard(text)
}
}
LaunchedEffect(Unit) {
vm.navigateToReceive.collect {
Log.d("WalletScreen", "LNURL-WITHDRAW [NAV RECEIVED] switching to Receive tab")
navController.navigate(TabItem.Receive.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
val intentUri = pendingPaymentUri.value
LaunchedEffect(intentUri) {
if (intentUri != null) {
Log.d("WalletScreen", "INTENT [URI] deep link received: $intentUri")
pendingPaymentUri.value = null
vm.scan(intentUri)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
}
}
// ── Clipboard offer ───────────────────────────────────────────────────────
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
LaunchedEffect(clipboardOffer) {
if (clipboardOffer is ClipboardOfferState.Detected) {
delay(8_000)
vm.dismissClipboardOffer()
}
}
// ── NFC offer (tag read while app is foregrounded) ────────────────────────
val nfcOffer by nfcVm.nfcOfferState.collectAsStateWithLifecycle()
LaunchedEffect(nfcOffer) {
if (nfcOffer is NfcOfferState.Detected) {
delay(8_000)
nfcVm.dismissNfcOffer()
}
}
Scaffold(
bottomBar = {
if (showBottomBar) {
// ── Floating island nav bar: Receive | [Scan] | Send ──────────
Box(
modifier = Modifier
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = 24.dp)
.padding(bottom = 16.dp),
contentAlignment = Alignment.Center
) {
Surface(
shape = MaterialTheme.shapes.extraLarge,
color = MaterialTheme.colorScheme.surfaceContainer,
tonalElevation = 6.dp,
shadowElevation = 6.dp,
modifier = Modifier.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Receive
NavigationBarItem(
selected = currentRoute == TabItem.Receive.route,
icon = { Icon(TabItem.Receive.icon, contentDescription = TabItem.Receive.label) },
label = { Text(TabItem.Receive.label) },
modifier = Modifier.weight(1f),
onClick = {
if (currentRoute != TabItem.Receive.route) {
navController.navigate(TabItem.Receive.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
)
// Scan — centre, elevated, not a nav destination
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.Center
) {
FilledIconButton(
onClick = { navController.navigate(ROUTE_SCANNER) },
modifier = Modifier.size(52.dp),
shape = CircleShape
) {
Icon(
imageVector = Icons.Filled.QrCodeScanner,
contentDescription = "Scan",
modifier = Modifier.size(26.dp)
)
}
}
// Send
NavigationBarItem(
selected = currentRoute == TabItem.Send.route,
icon = { Icon(TabItem.Send.icon, contentDescription = TabItem.Send.label) },
label = { Text(TabItem.Send.label) },
modifier = Modifier.weight(1f),
onClick = {
if (currentRoute != TabItem.Send.route) {
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
}
}
)
}
}
}
}
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = innerPadding.calculateBottomPadding())
) {
NavHost(
navController = navController,
startDestination = ROUTE_HOME,
modifier = if (isFullScreenDestination)
Modifier.fillMaxSize()
else
Modifier
.fillMaxSize()
.statusBarsPadding()
) {
composable(ROUTE_HOME) {
HomeTab(
vm = vm,
onHistoryClick = {
navController.navigate(ROUTE_HISTORY) {
launchSingleTop = true
}
}
)
}
composable(TabItem.Receive.route) {
ReceiveScreen(
vm = vm,
nfcVm = nfcVm, // ← new
onNavigateToPaymentDetail = { checkingId ->
navController.navigate("payment_detail/$checkingId")
}
)
}
composable(TabItem.Send.route) {
SendScreen(
vm = vm,
onViewDetails = { checkingId ->
historyVm.refresh()
navController.navigate("payment_detail/$checkingId") {
launchSingleTop = true
}
}
)
}
// Scanner — full screen, no bottom chrome
composable(ROUTE_SCANNER) {
QrScannerScreen(
onScanned = { scanned ->
navController.popBackStack()
vm.scan(scanned)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
},
onDismiss = { navController.popBackStack() }
)
}
// History — full screen, no bottom chrome, close → home
composable(
ROUTE_HISTORY,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
HistoryScreen(
vm = historyVm,
onClose = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onPaymentClick = { payment ->
navController.navigate("payment_detail/${payment.checkingId}")
}
)
}
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("checkingId") { type = NavType.StringType })
) { backStackEntry ->
val checkingId = backStackEntry.arguments?.getString("checkingId") ?: ""
val historyState by historyVm.filteredState.collectAsStateWithLifecycle()
val fallbackState by historyVm.state.collectAsStateWithLifecycle()
val payment = remember(historyState, fallbackState, checkingId) {
(historyState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
?: (fallbackState as? HistoryState.Success)
?.payments?.firstOrNull { it.checkingId == checkingId }
}
if (payment != null) {
PaymentDetailScreen(
payment = payment,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(checkingId) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
}
// ── Clipboard banner — floats over content, slides in from top ────
AnimatedVisibility(
visible = clipboardOffer is ClipboardOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(2f) // above NFC banner
.fillMaxWidth()
.statusBarsPadding()
) {
if (clipboardOffer is ClipboardOfferState.Detected) {
val offer = clipboardOffer as ClipboardOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
else -> "Payment"
}
val subLabel = when (offer.inputType) {
is SendInputType.Lnurl -> "Tap to open"
else -> "Tap to pay"
}
Surface(
color = MaterialTheme.colorScheme.primaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable {
vm.dismissClipboardOffer()
vm.scan(offer.raw)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.ContentPaste, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"$label detected in clipboard",
style = MaterialTheme.typography.bodyMedium
)
Text(
subLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
IconButton(onClick = { vm.dismissClipboardOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}
}
}
}
// ── NFC banner — floats over content, slides in from top ──────────
// Shown when an NFC tag is tapped while the app is foregrounded.
// Mirrors the clipboard banner pattern exactly.
AnimatedVisibility(
visible = nfcOffer is NfcOfferState.Detected,
enter = slideInVertically(initialOffsetY = { -it }),
exit = slideOutVertically(targetOffsetY = { -it }),
modifier = Modifier
.align(Alignment.TopCenter)
.zIndex(1f) // below clipboard banner if both show
.fillMaxWidth()
.statusBarsPadding()
) {
if (nfcOffer is NfcOfferState.Detected) {
val offer = nfcOffer as NfcOfferState.Detected
val label = when (offer.inputType) {
is SendInputType.Bolt11 -> "Invoice"
is SendInputType.Lnurl -> "LNURL"
is SendInputType.LightningAddress -> "Lightning Address"
else -> "Payment"
}
val subLabel = when (offer.inputType) {
is SendInputType.Lnurl -> "Tap to open"
else -> "Tap to pay"
}
Surface(
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier
.fillMaxWidth()
.clickable {
nfcVm.dismissNfcOffer()
vm.scan(offer.raw)
navController.navigate(TabItem.Send.route) {
launchSingleTop = true
}
}
) {
Row(
modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Nfc, contentDescription = null)
Spacer(Modifier.width(12.dp))
Column(Modifier.weight(1f)) {
Text(
"$label detected via NFC",
style = MaterialTheme.typography.bodyMedium
)
Text(
subLabel,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.7f)
)
}
IconButton(onClick = { nfcVm.dismissNfcOffer() }) {
Icon(Icons.Default.Close, contentDescription = "Dismiss")
}
}
}
}
}
}
}
}