initial commit
This commit is contained in:
@@ -0,0 +1,393 @@
|
||||
package com.example.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.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.example.gudariwallet.MainActivity
|
||||
import com.example.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/{paymentHash}"
|
||||
|
||||
@Composable
|
||||
fun WalletScreen(
|
||||
vm : WalletViewModel,
|
||||
pendingPaymentHash: MutableState<String?>,
|
||||
pendingPaymentUri : MutableState<String?> // SESSION 09: deep link / intent URI
|
||||
) {
|
||||
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 // consume immediately
|
||||
vm.scan(intentUri)
|
||||
navController.navigate(TabItem.Send.route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val clipboardOffer by vm.clipboardOfferState.collectAsStateWithLifecycle()
|
||||
|
||||
// Auto-dismiss banner after 8 seconds
|
||||
LaunchedEffect(clipboardOffer) {
|
||||
if (clipboardOffer is ClipboardOfferState.Detected) {
|
||||
delay(8_000)
|
||||
vm.dismissClipboardOffer()
|
||||
}
|
||||
}
|
||||
|
||||
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) }
|
||||
composable(TabItem.Send.route) { SendScreen(vm) }
|
||||
|
||||
// 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.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 historyState by historyVm.state.collectAsState()
|
||||
val payment = (historyState as? HistoryState.Success)
|
||||
?.payments
|
||||
?.firstOrNull { it.paymentHash == paymentHash }
|
||||
|
||||
if (payment != null) {
|
||||
PaymentDetailScreen(
|
||||
payment = payment,
|
||||
vm = historyVm,
|
||||
onBack = { navController.popBackStack() }
|
||||
)
|
||||
} else {
|
||||
LaunchedEffect(paymentHash) { 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(1f)
|
||||
.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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user