Files
gudari/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletScreen.kt
T
2026-06-15 00:33:29 +02:00

693 lines
33 KiB
Kotlin

package com.bitcointxoko.gudariwallet.ui
import android.content.ClipboardManager
import android.os.Build
import androidx.annotation.RequiresApi
import timber.log.Timber
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.contacts.ContactsScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactsViewModel
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.nwc.ConnectionDetailScreen
import com.bitcointxoko.gudariwallet.ui.nwc.ConnectionDetailState
import com.bitcointxoko.gudariwallet.ui.nwc.NwcScreen
import com.bitcointxoko.gudariwallet.ui.nwc.NwcViewModel
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 com.bitcointxoko.gudariwallet.data.PaymentFeedRepository
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailScreen
import com.bitcointxoko.gudariwallet.ui.contacts.ContactDetailViewModel
import kotlinx.coroutines.delay
import kotlin.time.Duration.Companion.milliseconds
// ── 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/{paymentHash}"
private const val ROUTE_NWC = "nwc"
private const val ROUTE_CONNECTION_DETAIL = "connection_detail/{pubkey}"
private const val ROUTE_CONTACTS = "contacts"
private const val ROUTE_CONTACT_DETAIL = "contacts/{contactId}"
@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
@Composable
fun WalletScreen(
vm : WalletViewModel,
nfcVm : NfcViewModel,
pendingPaymentHash: MutableState<String?>,
pendingNavigateToHistory : MutableState<Boolean>,
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,
contactRepo = vm.contactRepo,
feedRepository = PaymentFeedRepository(
paymentCache = vm.paymentCache,
contactRepo = vm.contactRepo,
nodeAliasRepository = vm.aliasRepo,
fiatVm = vm.fiatVm
),
fiatCurrency = vm.selectedCurrency,
fiatSatsPerUnit = vm.fiatSatsPerUnit
)
}
val historyVm: HistoryViewModel = viewModel(factory = historyFactory)
val nwcVm: NwcViewModel = viewModel(
factory = NwcViewModel.Factory(repo = vm.repo, cache = vm.nwcCache) // same pattern as historyVm
)
val contactsVm: ContactsViewModel = viewModel(
factory = ContactsViewModel.Factory(vm.contactRepo)
)
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentRoute = navBackStackEntry?.destination?.route
val showBottomBar = currentRoute != ROUTE_PAYMENT_DETAIL
&& currentRoute != ROUTE_SCANNER
&& currentRoute != ROUTE_HISTORY
&& currentRoute != ROUTE_NWC
&& currentRoute != ROUTE_CONNECTION_DETAIL
&& currentRoute != ROUTE_CONTACTS
&& currentRoute != ROUTE_CONTACT_DETAIL
val isFullScreenDestination = currentRoute == ROUTE_HISTORY
|| currentRoute == ROUTE_PAYMENT_DETAIL
|| currentRoute == ROUTE_SCANNER
|| currentRoute == ROUTE_NWC
|| currentRoute == ROUTE_CONNECTION_DETAIL
|| currentRoute == ROUTE_CONTACTS
|| currentRoute == ROUTE_CONTACT_DETAIL
// ── Notification tap ──────────────────────────────────────────────────────
val hash = pendingPaymentHash.value
LaunchedEffect(hash) {
if (hash != null) {
navController.navigate("payment_detail/$hash") {
launchSingleTop = true
}
pendingPaymentHash.value = null
}
}
val navigateToHistory = pendingNavigateToHistory.value
LaunchedEffect(navigateToHistory) {
if (navigateToHistory) {
navController.navigate(ROUTE_HISTORY) {
launchSingleTop = true
}
pendingNavigateToHistory.value = false
}
}
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 {
Timber.d("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) {
Timber.d("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.milliseconds)
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.milliseconds)
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
}
},
onNwcClick = {
navController.navigate(ROUTE_NWC) {
launchSingleTop = true
}
},
onContactsClick = {
navController.navigate(ROUTE_CONTACTS) { launchSingleTop = true }
},
lyricist = lyricist
)
}
composable(TabItem.Receive.route) {
ReceiveScreen(
vm = vm,
nfcVm = nfcVm,
onNavigateToPaymentDetail = { paymentHash ->
navController.navigate("payment_detail/$paymentHash")
}
)
}
composable(TabItem.Send.route) {
SendScreen(
vm = vm,
nfcVm = nfcVm,
onViewDetails = { paymentHash, record ->
historyVm.primePayment(record)
navController.navigate("payment_detail/$paymentHash") {
launchSingleTop = true
}
},
onViewContact = { contactId ->
navController.navigate("contacts/$contactId") {
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 = { item ->
navController.navigate("payment_detail/${item.paymentHash}")
}
)
}
// Payment detail — back → history (natural back stack)
composable(
route = ROUTE_PAYMENT_DETAIL,
arguments = listOf(navArgument("paymentHash") { type = NavType.StringType })
) { backStackEntry ->
val paymentHash = backStackEntry.arguments?.getString("paymentHash") ?: ""
val feedItems by historyVm.feedItems.collectAsStateWithLifecycle()
val item = remember(feedItems, paymentHash) {
historyVm.findPayment(paymentHash)
}
if (item != null) {
PaymentDetailScreen(
item = item,
vm = historyVm,
onBack = { navController.popBackStack() }
)
} else {
LaunchedEffect(paymentHash) { historyVm.refresh() }
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
}
composable(
ROUTE_NWC,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
NwcScreen(
viewModel = nwcVm,
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
},
onConnectionClick = { pubkey ->
navController.navigate("connection_detail/$pubkey") {
launchSingleTop = true
}
}
)
}
composable(
route = ROUTE_CONNECTION_DETAIL,
arguments = listOf(navArgument("pubkey") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val pubkey = backStackEntry.arguments?.getString("pubkey") ?: ""
val connectionState by nwcVm.connectionDetailState.collectAsStateWithLifecycle()
LaunchedEffect(pubkey) { nwcVm.loadConnectionDetail(pubkey, strings) }
DisposableEffect(Unit) { onDispose { nwcVm.clearConnectionDetail() } }
when (val state = connectionState) {
is ConnectionDetailState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) { CircularProgressIndicator() }
}
is ConnectionDetailState.Error -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = state.message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium
)
}
}
is ConnectionDetailState.Success -> {
ConnectionDetailScreen(
connection = state.connection,
budgets = state.budgets,
onBack = { navController.popBackStack() },
onRevoke = {
nwcVm.deleteConnection(pubkey, strings)
navController.popBackStack()
}
)
}
}
}
composable(
ROUTE_CONTACTS,
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) {
ContactsScreen(
viewModel = contactsVm,
onContactClick = { contactId ->
navController.navigate("contacts/$contactId") {
launchSingleTop = true
}
},
onBack = {
navController.popBackStack(ROUTE_HOME, inclusive = false)
}
)
}
composable(
route = ROUTE_CONTACT_DETAIL,
arguments = listOf(navArgument("contactId") { type = NavType.StringType }),
enterTransition = { fadeIn(animationSpec = tween(150)) },
popExitTransition = { fadeOut(animationSpec = tween(150)) }
) { backStackEntry ->
val contactId = backStackEntry.arguments?.getString("contactId") ?: ""
val detailVm: ContactDetailViewModel = viewModel(
key = contactId,
factory = ContactDetailViewModel.Factory(contactId, vm.contactRepo)
)
ContactDetailScreen(
viewModel = detailVm,
onBack = { navController.popBackStack() },
onPayAddress = { address ->
vm.prefillSend(address)
navController.navigate(TabItem.Send.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = false // don't restore stale SendScreen state
}
}
)
}
}
// ── 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
}
}
}
}
}
}
}
}