526 lines
25 KiB
Kotlin
526 lines
25 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 cafe.adriel.lyricist.Lyricist
|
|
import com.bitcointxoko.gudariwallet.MainActivity
|
|
import com.bitcointxoko.gudariwallet.i18n.AppStrings
|
|
import com.bitcointxoko.gudariwallet.LocalAppStrings
|
|
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.ui.send.SendStrings
|
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
|
import kotlinx.coroutines.delay
|
|
|
|
// ── Tab destinations ──────────────────────────────────────────────────────────
|
|
// Note: label and icon are now resolved at the call site via LocalAppStrings,
|
|
// so the sealed class carries only the stable route string and icon vector.
|
|
|
|
sealed class TabItem(val route: String, val icon: ImageVector) {
|
|
data object Receive : TabItem("receive", Icons.Filled.QrCode)
|
|
data object Send : TabItem("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,
|
|
pendingPaymentHash: MutableState<String?>,
|
|
pendingPaymentUri : MutableState<String?>,
|
|
lyricist : Lyricist<AppStrings>
|
|
) {
|
|
val strings = LocalAppStrings.current
|
|
val sendStrings = remember(strings) {
|
|
SendStrings(
|
|
lnurlAuthNotSupported = strings.sendVmLnurlAuthNotSupported,
|
|
unrecognisedInput = strings.sendVmUnrecognisedInput,
|
|
paymentFailed = strings.sendVmPaymentFailed,
|
|
couldNotFetchInvoice = strings.sendVmCouldNotFetchInvoice,
|
|
couldNotDecodeInvoice = strings.sendVmCouldNotDecodeInvoice,
|
|
onChainNotSupported = strings.sendVmOnChainNotSupported,
|
|
channelRequestNotSupported = strings.sendVmChannelRequestNotSupported,
|
|
couldNotResolveAddress = strings.sendVmCouldNotResolveAddress,
|
|
emptyResponse = strings.sendVmEmptyResponse,
|
|
unsupportedType = strings.sendVmUnsupportedType,
|
|
paymentFailedRescan = strings.sendVmPaymentFailedRescan,
|
|
authFailed = strings.sendVmAuthFailed,
|
|
)
|
|
}
|
|
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, sendStrings)
|
|
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) {
|
|
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 = strings.tabReceive) }, // ← localized
|
|
label = { Text(strings.tabReceive) }, // ← localized
|
|
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) {
|
|
popUpTo(TabItem.Send.route) {
|
|
saveState = true
|
|
inclusive = false
|
|
}
|
|
}
|
|
},
|
|
modifier = Modifier.size(52.dp),
|
|
shape = CircleShape
|
|
) {
|
|
Icon(
|
|
imageVector = Icons.Filled.QrCodeScanner,
|
|
contentDescription = strings.scanContentDescription, // ← localized
|
|
modifier = Modifier.size(26.dp)
|
|
)
|
|
}
|
|
}
|
|
|
|
// Send
|
|
NavigationBarItem(
|
|
selected = currentRoute == TabItem.Send.route,
|
|
icon = { Icon(TabItem.Send.icon, contentDescription = strings.tabSend) }, // ← localized
|
|
label = { Text(strings.tabSend) }, // ← localized
|
|
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
|
|
}
|
|
},
|
|
lyricist = lyricist
|
|
)
|
|
}
|
|
composable(TabItem.Receive.route) {
|
|
ReceiveScreen(
|
|
vm = vm,
|
|
nfcVm = nfcVm,
|
|
onNavigateToPaymentDetail = { checkingId ->
|
|
navController.navigate("payment_detail/$checkingId")
|
|
}
|
|
)
|
|
}
|
|
composable(TabItem.Send.route) {
|
|
SendScreen(
|
|
vm = vm,
|
|
nfcVm = nfcVm,
|
|
onViewDetails = { checkingId, record ->
|
|
historyVm.primePayment(record)
|
|
navController.navigate("payment_detail/$checkingId") {
|
|
launchSingleTop = true
|
|
}
|
|
},
|
|
sendStrings
|
|
)
|
|
}
|
|
|
|
// Scanner — full screen, no bottom chrome
|
|
composable(ROUTE_SCANNER) {
|
|
QrScannerScreen(
|
|
onScanned = { scanned ->
|
|
vm.scan(scanned, sendStrings)
|
|
val wentBack = navController.popBackStack(TabItem.Send.route, inclusive = false)
|
|
if (!wentBack) {
|
|
navController.navigate(TabItem.Send.route) {
|
|
popUpTo(navController.graph.findStartDestination().id) {
|
|
saveState = true
|
|
}
|
|
launchSingleTop = true
|
|
restoreState = false
|
|
}
|
|
}
|
|
},
|
|
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)
|
|
.fillMaxWidth()
|
|
.statusBarsPadding()
|
|
) {
|
|
if (clipboardOffer is ClipboardOfferState.Detected) {
|
|
val offer = clipboardOffer as ClipboardOfferState.Detected
|
|
val label = when (offer.inputType) {
|
|
is SendInputType.Bolt11 -> strings.clipboardInvoiceDetected // ← localized
|
|
is SendInputType.Lnurl -> strings.clipboardLnurlDetected // ← localized
|
|
is SendInputType.LightningAddress -> strings.clipboardAddressDetected // ← localized
|
|
else -> strings.clipboardPaymentDetected // ← localized
|
|
}
|
|
val subLabel = when (offer.inputType) {
|
|
is SendInputType.Lnurl -> strings.clipboardTapToOpen // ← localized
|
|
else -> strings.clipboardTapToPay // ← localized
|
|
}
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.primaryContainer,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable {
|
|
vm.dismissClipboardOffer()
|
|
vm.scan(offer.raw, sendStrings)
|
|
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, 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 = strings.dismissContentDescription) // ← localized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── NFC banner — floats over content, slides in from top ──────────
|
|
AnimatedVisibility(
|
|
visible = nfcOffer is NfcOfferState.Detected,
|
|
enter = slideInVertically(initialOffsetY = { -it }),
|
|
exit = slideOutVertically(targetOffsetY = { -it }),
|
|
modifier = Modifier
|
|
.align(Alignment.TopCenter)
|
|
.zIndex(1f)
|
|
.fillMaxWidth()
|
|
.statusBarsPadding()
|
|
) {
|
|
if (nfcOffer is NfcOfferState.Detected) {
|
|
val offer = nfcOffer as NfcOfferState.Detected
|
|
val label = when (offer.inputType) {
|
|
is SendInputType.Bolt11 -> strings.nfcInvoiceDetected // ← localized
|
|
is SendInputType.Lnurl -> strings.nfcLnurlDetected // ← localized
|
|
is SendInputType.LightningAddress -> strings.nfcAddressDetected // ← localized
|
|
else -> strings.nfcPaymentDetected // ← localized
|
|
}
|
|
val subLabel = when (offer.inputType) {
|
|
is SendInputType.Lnurl -> strings.nfcTapToOpen // ← localized
|
|
else -> strings.nfcTapToPay // ← localized
|
|
}
|
|
Surface(
|
|
color = MaterialTheme.colorScheme.secondaryContainer,
|
|
modifier = Modifier
|
|
.fillMaxWidth()
|
|
.clickable {
|
|
vm.scan(offer.raw, sendStrings)
|
|
nfcVm.dismissNfcOffer()
|
|
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, 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 = strings.dismissContentDescription) // ← localized
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|