mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 04:45:21 +00:00
worky
This commit is contained in:
@@ -34,6 +34,9 @@ import com.bitchat.android.model.BitchatMessage
|
|||||||
import com.bitchat.android.model.DeliveryStatus
|
import com.bitchat.android.model.DeliveryStatus
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
import com.bitchat.android.parsing.ParsedCashuToken
|
import com.bitchat.android.parsing.ParsedCashuToken
|
||||||
|
import com.bitchat.android.ui.payment.PaymentStatusIndicator
|
||||||
|
import com.bitchat.android.ui.payment.PaymentStatus
|
||||||
|
import com.bitchat.android.wallet.viewmodel.WalletViewModel
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@@ -51,9 +54,17 @@ import java.util.*
|
|||||||
@Composable
|
@Composable
|
||||||
fun ChatScreen(
|
fun ChatScreen(
|
||||||
viewModel: ChatViewModel,
|
viewModel: ChatViewModel,
|
||||||
|
walletViewModel: WalletViewModel? = null,
|
||||||
onWalletClick: () -> Unit = {},
|
onWalletClick: () -> Unit = {},
|
||||||
onWalletClickWithToken: ((com.bitchat.android.parsing.ParsedCashuToken) -> Unit)? = null
|
onWalletClickWithToken: ((com.bitchat.android.parsing.ParsedCashuToken) -> Unit)? = null
|
||||||
) {
|
) {
|
||||||
|
// Initialize payment manager when wallet ViewModel is available
|
||||||
|
LaunchedEffect(walletViewModel) {
|
||||||
|
walletViewModel?.let { wallet ->
|
||||||
|
viewModel.initializePaymentManager(wallet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
val messages by viewModel.messages.observeAsState(emptyList())
|
val messages by viewModel.messages.observeAsState(emptyList())
|
||||||
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
val connectedPeers by viewModel.connectedPeers.observeAsState(emptyList())
|
||||||
@@ -70,6 +81,9 @@ fun ChatScreen(
|
|||||||
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
val commandSuggestions by viewModel.commandSuggestions.observeAsState(emptyList())
|
||||||
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
val showAppInfo by viewModel.showAppInfo.observeAsState(false)
|
||||||
|
|
||||||
|
// Observe payment status
|
||||||
|
val paymentStatus by viewModel.getPaymentStatus().collectAsState()
|
||||||
|
|
||||||
var messageText by remember { mutableStateOf("") }
|
var messageText by remember { mutableStateOf("") }
|
||||||
var showPasswordPrompt by remember { mutableStateOf(false) }
|
var showPasswordPrompt by remember { mutableStateOf(false) }
|
||||||
var showPasswordDialog by remember { mutableStateOf(false) }
|
var showPasswordDialog by remember { mutableStateOf(false) }
|
||||||
@@ -139,7 +153,9 @@ fun ChatScreen(
|
|||||||
selectedPrivatePeer = selectedPrivatePeer,
|
selectedPrivatePeer = selectedPrivatePeer,
|
||||||
currentChannel = currentChannel,
|
currentChannel = currentChannel,
|
||||||
nickname = nickname,
|
nickname = nickname,
|
||||||
colorScheme = colorScheme
|
colorScheme = colorScheme,
|
||||||
|
paymentStatus = paymentStatus,
|
||||||
|
onClearPaymentStatus = { viewModel.clearPaymentStatus() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,7 +230,9 @@ private fun ChatInputSection(
|
|||||||
selectedPrivatePeer: String?,
|
selectedPrivatePeer: String?,
|
||||||
currentChannel: String?,
|
currentChannel: String?,
|
||||||
nickname: String,
|
nickname: String,
|
||||||
colorScheme: ColorScheme
|
colorScheme: ColorScheme,
|
||||||
|
paymentStatus: PaymentStatus?,
|
||||||
|
onClearPaymentStatus: () -> Unit
|
||||||
) {
|
) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@@ -235,6 +253,13 @@ private fun ChatInputSection(
|
|||||||
Divider(color = colorScheme.outline.copy(alpha = 0.2f))
|
Divider(color = colorScheme.outline.copy(alpha = 0.2f))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Payment status indicator
|
||||||
|
PaymentStatusIndicator(
|
||||||
|
status = paymentStatus,
|
||||||
|
onDismiss = onClearPaymentStatus,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
)
|
||||||
|
|
||||||
MessageInput(
|
MessageInput(
|
||||||
value = messageText,
|
value = messageText,
|
||||||
onValueChange = onMessageTextChange,
|
onValueChange = onMessageTextChange,
|
||||||
|
|||||||
@@ -6,19 +6,25 @@ import android.util.Log
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.LiveData
|
import androidx.lifecycle.LiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
|
||||||
import com.bitchat.android.mesh.BluetoothMeshService
|
import com.bitchat.android.mesh.BluetoothMeshService
|
||||||
|
import com.bitchat.android.mesh.BluetoothMeshDelegate
|
||||||
import com.bitchat.android.model.BitchatMessage
|
import com.bitchat.android.model.BitchatMessage
|
||||||
import com.bitchat.android.model.DeliveryAck
|
import com.bitchat.android.model.DeliveryAck
|
||||||
import com.bitchat.android.model.ReadReceipt
|
import com.bitchat.android.model.ReadReceipt
|
||||||
|
import com.bitchat.android.model.RoutedPacket
|
||||||
|
import com.bitchat.android.ui.payment.PaymentManager
|
||||||
|
import com.bitchat.android.ui.payment.PaymentStatus
|
||||||
|
import com.bitchat.android.wallet.viewmodel.WalletViewModel
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import java.util.*
|
import java.util.*
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Refactored ChatViewModel - Main coordinator for bitchat functionality
|
* ViewModel for managing the chat functionality
|
||||||
* Delegates specific responsibilities to specialized managers while maintaining 100% iOS compatibility
|
* Refactored to use composition over inheritance for better code organization
|
||||||
*/
|
*/
|
||||||
class ChatViewModel(
|
class ChatViewModel(
|
||||||
application: Application,
|
application: Application,
|
||||||
@@ -38,6 +44,14 @@ class ChatViewModel(
|
|||||||
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
private val commandProcessor = CommandProcessor(state, messageManager, channelManager, privateChatManager)
|
||||||
private val notificationManager = NotificationManager(application.applicationContext)
|
private val notificationManager = NotificationManager(application.applicationContext)
|
||||||
|
|
||||||
|
// Payment manager (lazily initialized with wallet ViewModel)
|
||||||
|
private var paymentManager: PaymentManager? = null
|
||||||
|
|
||||||
|
// Initialize payment manager with wallet ViewModel
|
||||||
|
fun initializePaymentManager(walletViewModel: WalletViewModel) {
|
||||||
|
paymentManager = PaymentManager(viewModelScope, walletViewModel)
|
||||||
|
}
|
||||||
|
|
||||||
// Delegate handler for mesh callbacks
|
// Delegate handler for mesh callbacks
|
||||||
private val meshDelegateHandler = MeshDelegateHandler(
|
private val meshDelegateHandler = MeshDelegateHandler(
|
||||||
state = state,
|
state = state,
|
||||||
@@ -170,31 +184,39 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Message Sending
|
// MARK: - Message Sending
|
||||||
|
|
||||||
fun sendMessage(content: String) {
|
fun sendMessage(content: String) {
|
||||||
if (content.isEmpty()) return
|
if (content.isEmpty()) return
|
||||||
|
|
||||||
// Check for commands
|
// Check for commands
|
||||||
if (content.startsWith("/")) {
|
if (content.startsWith("/")) {
|
||||||
commandProcessor.processCommand(content, meshService, meshService.myPeerID) { messageContent, mentions, channel ->
|
commandProcessor.processCommand(
|
||||||
meshService.sendMessage(messageContent, mentions, channel)
|
command = content,
|
||||||
}
|
meshService = meshService,
|
||||||
|
myPeerID = meshService.myPeerID,
|
||||||
|
onSendMessage = { messageContent, mentions, channel ->
|
||||||
|
meshService.sendMessage(messageContent, mentions, channel)
|
||||||
|
},
|
||||||
|
onPaymentRequest = { amount ->
|
||||||
|
handlePaymentRequest(amount)
|
||||||
|
}
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
val mentions = messageManager.parseMentions(content, meshService.getPeerNicknames().values.toSet(), state.getNicknameValue())
|
||||||
val channels = messageManager.parseChannels(content)
|
val channels = messageManager.parseChannels(content)
|
||||||
|
|
||||||
// Auto-join mentioned channels
|
// Auto-join mentioned channels
|
||||||
channels.forEach { channel ->
|
channels.forEach { channel ->
|
||||||
if (!state.getJoinedChannelsValue().contains(channel)) {
|
if (!state.getJoinedChannelsValue().contains(channel)) {
|
||||||
joinChannel(channel)
|
joinChannel(channel)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||||
val currentChannelValue = state.getCurrentChannelValue()
|
val currentChannelValue = state.getCurrentChannelValue()
|
||||||
|
|
||||||
if (selectedPeer != null) {
|
if (selectedPeer != null) {
|
||||||
// Send private message
|
// Send private message
|
||||||
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
||||||
@@ -248,6 +270,72 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Payment Management
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle payment request from /pay command
|
||||||
|
*/
|
||||||
|
private fun handlePaymentRequest(amount: Long) {
|
||||||
|
paymentManager?.createPayment(amount) { token ->
|
||||||
|
// Send the created token to the current chat
|
||||||
|
sendCashuTokenToChat(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a Cashu token to the current chat
|
||||||
|
*/
|
||||||
|
private fun sendCashuTokenToChat(token: String) {
|
||||||
|
val selectedPeer = state.getSelectedPrivateChatPeerValue()
|
||||||
|
val currentChannelValue = state.getCurrentChannelValue()
|
||||||
|
|
||||||
|
if (selectedPeer != null) {
|
||||||
|
// Send token as private message
|
||||||
|
val recipientNickname = meshService.getPeerNicknames()[selectedPeer]
|
||||||
|
privateChatManager.sendPrivateMessage(
|
||||||
|
token,
|
||||||
|
selectedPeer,
|
||||||
|
recipientNickname,
|
||||||
|
state.getNicknameValue(),
|
||||||
|
meshService.myPeerID
|
||||||
|
) { messageContent, peerID, recipientNicknameParam, messageId ->
|
||||||
|
meshService.sendPrivateMessage(messageContent, peerID, recipientNicknameParam, messageId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Send token as public/channel message
|
||||||
|
val message = BitchatMessage(
|
||||||
|
sender = state.getNicknameValue() ?: meshService.myPeerID,
|
||||||
|
content = token,
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false,
|
||||||
|
senderPeerID = meshService.myPeerID,
|
||||||
|
channel = currentChannelValue
|
||||||
|
)
|
||||||
|
|
||||||
|
if (currentChannelValue != null) {
|
||||||
|
channelManager.addChannelMessage(currentChannelValue, message, meshService.myPeerID)
|
||||||
|
meshService.sendMessage(token, emptyList(), currentChannelValue)
|
||||||
|
} else {
|
||||||
|
messageManager.addMessage(message)
|
||||||
|
meshService.sendMessage(token, emptyList(), null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get payment status for UI
|
||||||
|
*/
|
||||||
|
fun getPaymentStatus(): StateFlow<PaymentStatus?> {
|
||||||
|
return paymentManager?.paymentStatus ?: kotlinx.coroutines.flow.MutableStateFlow(null).asStateFlow()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear payment status
|
||||||
|
*/
|
||||||
|
fun clearPaymentStatus() {
|
||||||
|
paymentManager?.clearStatus()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Utility Functions
|
// MARK: - Utility Functions
|
||||||
|
|
||||||
fun getPeerIDForNickname(nickname: String): String? {
|
fun getPeerIDForNickname(nickname: String): String? {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ class CommandProcessor(
|
|||||||
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
|
CommandSuggestion("/hug", emptyList(), "<nickname>", "send someone a warm hug"),
|
||||||
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
CommandSuggestion("/j", listOf("/join"), "<channel>", "join or create a channel"),
|
||||||
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
CommandSuggestion("/m", listOf("/msg"), "<nickname> [message]", "send private message"),
|
||||||
|
CommandSuggestion("/pay", emptyList(), "<amount>", "send Cashu payment"),
|
||||||
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
|
CommandSuggestion("/slap", emptyList(), "<nickname>", "slap someone with a trout"),
|
||||||
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
|
CommandSuggestion("/unblock", emptyList(), "<nickname>", "unblock a peer"),
|
||||||
CommandSuggestion("/w", emptyList(), null, "see who's online")
|
CommandSuggestion("/w", emptyList(), null, "see who's online")
|
||||||
@@ -28,7 +29,13 @@ class CommandProcessor(
|
|||||||
|
|
||||||
// MARK: - Command Processing
|
// MARK: - Command Processing
|
||||||
|
|
||||||
fun processCommand(command: String, meshService: Any, myPeerID: String, onSendMessage: (String, List<String>, String?) -> Unit): Boolean {
|
fun processCommand(
|
||||||
|
command: String,
|
||||||
|
meshService: Any,
|
||||||
|
myPeerID: String,
|
||||||
|
onSendMessage: (String, List<String>, String?) -> Unit,
|
||||||
|
onPaymentRequest: ((Long) -> Unit)? = null
|
||||||
|
): Boolean {
|
||||||
if (!command.startsWith("/")) return false
|
if (!command.startsWith("/")) return false
|
||||||
|
|
||||||
val parts = command.split(" ")
|
val parts = command.split(" ")
|
||||||
@@ -45,6 +52,7 @@ class CommandProcessor(
|
|||||||
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
"/hug" -> handleActionCommand(parts, "gives", "a warm hug 🫂", meshService, myPeerID, onSendMessage)
|
||||||
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
|
"/slap" -> handleActionCommand(parts, "slaps", "around a bit with a large trout 🐟", meshService, myPeerID, onSendMessage)
|
||||||
"/channels" -> handleChannelsCommand()
|
"/channels" -> handleChannelsCommand()
|
||||||
|
"/pay" -> handlePayCommand(parts, onPaymentRequest)
|
||||||
else -> handleUnknownCommand(cmd)
|
else -> handleUnknownCommand(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +324,47 @@ class CommandProcessor(
|
|||||||
messageManager.addMessage(systemMessage)
|
messageManager.addMessage(systemMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun handlePayCommand(parts: List<String>, onPaymentRequest: ((Long) -> Unit)?) {
|
||||||
|
if (parts.size < 2) {
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "usage: /pay <amount> - send Cashu payment in sats",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(systemMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val amountStr = parts[1]
|
||||||
|
val amount = amountStr.toLongOrNull()
|
||||||
|
|
||||||
|
if (amount == null || amount <= 0) {
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "invalid amount: '$amountStr'. please enter a positive number of sats.",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(systemMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amount > 1_000_000) {
|
||||||
|
val systemMessage = BitchatMessage(
|
||||||
|
sender = "system",
|
||||||
|
content = "amount too large: $amount sats. maximum is 1,000,000 sats.",
|
||||||
|
timestamp = Date(),
|
||||||
|
isRelay = false
|
||||||
|
)
|
||||||
|
messageManager.addMessage(systemMessage)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger payment creation
|
||||||
|
onPaymentRequest?.invoke(amount)
|
||||||
|
}
|
||||||
|
|
||||||
private fun handleUnknownCommand(cmd: String) {
|
private fun handleUnknownCommand(cmd: String) {
|
||||||
val systemMessage = BitchatMessage(
|
val systemMessage = BitchatMessage(
|
||||||
sender = "system",
|
sender = "system",
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fun MainAppScreen(
|
|||||||
// Chat is always the base view
|
// Chat is always the base view
|
||||||
ChatScreen(
|
ChatScreen(
|
||||||
viewModel = chatViewModel,
|
viewModel = chatViewModel,
|
||||||
|
walletViewModel = walletViewModel,
|
||||||
onWalletClick = { showWallet = true },
|
onWalletClick = { showWallet = true },
|
||||||
onWalletClickWithToken = { parsedToken ->
|
onWalletClickWithToken = { parsedToken ->
|
||||||
// Open wallet and show receive dialog with the parsed token immediately
|
// Open wallet and show receive dialog with the parsed token immediately
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package com.bitchat.android.ui.payment
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import com.bitchat.android.wallet.viewmodel.WalletViewModel
|
||||||
|
import com.bitchat.android.wallet.data.TransactionType
|
||||||
|
import com.bitchat.android.wallet.data.TransactionStatus
|
||||||
|
import com.bitchat.android.wallet.data.WalletTransaction
|
||||||
|
import com.bitchat.android.model.BitchatMessage
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import java.math.BigDecimal
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manages payment creation and status for the /pay command
|
||||||
|
* Coordinates between ChatViewModel, WalletViewModel, and UI
|
||||||
|
*/
|
||||||
|
class PaymentManager(
|
||||||
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val walletViewModel: WalletViewModel
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "PaymentManager"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Payment status state
|
||||||
|
private val _paymentStatus = MutableStateFlow<PaymentStatus?>(null)
|
||||||
|
val paymentStatus: StateFlow<PaymentStatus?> = _paymentStatus.asStateFlow()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Cashu token payment for the specified amount
|
||||||
|
*/
|
||||||
|
fun createPayment(
|
||||||
|
amount: Long,
|
||||||
|
onTokenCreated: (String) -> Unit
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "Creating payment for $amount sats")
|
||||||
|
|
||||||
|
// Set creating status
|
||||||
|
_paymentStatus.value = PaymentStatus.Creating(amount)
|
||||||
|
|
||||||
|
coroutineScope.launch {
|
||||||
|
try {
|
||||||
|
// Use WalletViewModel's createCashuToken method
|
||||||
|
walletViewModel.createCashuTokenForPayment(
|
||||||
|
amount = amount,
|
||||||
|
memo = "Payment via /pay command",
|
||||||
|
onSuccess = { token ->
|
||||||
|
Log.d(TAG, "Payment token created successfully: ${token.take(20)}...")
|
||||||
|
|
||||||
|
// Set success status
|
||||||
|
_paymentStatus.value = PaymentStatus.Success(amount, token)
|
||||||
|
|
||||||
|
// Notify caller with the token
|
||||||
|
onTokenCreated(token)
|
||||||
|
},
|
||||||
|
onError = { error ->
|
||||||
|
Log.e(TAG, "Payment creation failed: $error")
|
||||||
|
|
||||||
|
// Set error status
|
||||||
|
_paymentStatus.value = PaymentStatus.Error(amount, error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception creating payment", e)
|
||||||
|
_paymentStatus.value = PaymentStatus.Error(
|
||||||
|
amount,
|
||||||
|
e.message ?: "Unknown error occurred"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the current payment status
|
||||||
|
*/
|
||||||
|
fun clearStatus() {
|
||||||
|
_paymentStatus.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a payment is currently being created
|
||||||
|
*/
|
||||||
|
fun isCreatingPayment(): Boolean {
|
||||||
|
return _paymentStatus.value is PaymentStatus.Creating
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
package com.bitchat.android.ui.payment
|
||||||
|
|
||||||
|
import androidx.compose.animation.*
|
||||||
|
import androidx.compose.animation.core.*
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.*
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment status for Cashu token creation
|
||||||
|
*/
|
||||||
|
sealed class PaymentStatus {
|
||||||
|
data class Creating(val amount: Long) : PaymentStatus()
|
||||||
|
data class Success(val amount: Long, val token: String) : PaymentStatus()
|
||||||
|
data class Error(val amount: Long, val message: String) : PaymentStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sleek payment status indicator that slides up above the text input
|
||||||
|
* Shows creation progress, success, and error states
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun PaymentStatusIndicator(
|
||||||
|
status: PaymentStatus?,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val isVisible = status != null
|
||||||
|
|
||||||
|
// Auto-dismiss success/error states after delay
|
||||||
|
LaunchedEffect(status) {
|
||||||
|
if (status is PaymentStatus.Success || status is PaymentStatus.Error) {
|
||||||
|
delay(3000) // Show for 3 seconds
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = isVisible,
|
||||||
|
enter = slideInVertically(
|
||||||
|
initialOffsetY = { it },
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.8f,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeIn(
|
||||||
|
animationSpec = tween(300, easing = EaseOutCubic)
|
||||||
|
),
|
||||||
|
exit = slideOutVertically(
|
||||||
|
targetOffsetY = { it },
|
||||||
|
animationSpec = spring(
|
||||||
|
dampingRatio = 0.9f,
|
||||||
|
stiffness = Spring.StiffnessMedium
|
||||||
|
)
|
||||||
|
) + fadeOut(
|
||||||
|
animationSpec = tween(200, easing = EaseInCubic)
|
||||||
|
),
|
||||||
|
modifier = modifier
|
||||||
|
) {
|
||||||
|
if (status != null) {
|
||||||
|
PaymentStatusCard(status = status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentStatusCard(status: PaymentStatus) {
|
||||||
|
val colors = when (status) {
|
||||||
|
is PaymentStatus.Creating -> PaymentColors(
|
||||||
|
background = Color(0xFF1A1A1A),
|
||||||
|
border = Color(0xFF00C851),
|
||||||
|
icon = Color(0xFF00C851),
|
||||||
|
text = Color.White
|
||||||
|
)
|
||||||
|
is PaymentStatus.Success -> PaymentColors(
|
||||||
|
background = Color(0xFF00C851).copy(alpha = 0.15f),
|
||||||
|
border = Color(0xFF00C851),
|
||||||
|
icon = Color(0xFF00C851),
|
||||||
|
text = Color.White
|
||||||
|
)
|
||||||
|
is PaymentStatus.Error -> PaymentColors(
|
||||||
|
background = Color(0xFFFF4444).copy(alpha = 0.15f),
|
||||||
|
border = Color(0xFFFF4444),
|
||||||
|
icon = Color(0xFFFF4444),
|
||||||
|
text = Color.White
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Card(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp),
|
||||||
|
shape = RoundedCornerShape(16.dp),
|
||||||
|
colors = CardDefaults.cardColors(
|
||||||
|
containerColor = colors.background
|
||||||
|
),
|
||||||
|
border = androidx.compose.foundation.BorderStroke(2.dp, colors.border)
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
// Status icon with animation
|
||||||
|
PaymentStatusIcon(
|
||||||
|
status = status,
|
||||||
|
color = colors.icon
|
||||||
|
)
|
||||||
|
|
||||||
|
// Status content
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
PaymentStatusText(
|
||||||
|
status = status,
|
||||||
|
textColor = colors.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amount display
|
||||||
|
PaymentAmountDisplay(
|
||||||
|
amount = when (status) {
|
||||||
|
is PaymentStatus.Creating -> status.amount
|
||||||
|
is PaymentStatus.Success -> status.amount
|
||||||
|
is PaymentStatus.Error -> status.amount
|
||||||
|
},
|
||||||
|
color = colors.text
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentStatusIcon(
|
||||||
|
status: PaymentStatus,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
is PaymentStatus.Creating -> {
|
||||||
|
// Animated spinner
|
||||||
|
val infiniteTransition = rememberInfiniteTransition(label = "payment_creating")
|
||||||
|
val rotation by infiniteTransition.animateFloat(
|
||||||
|
initialValue = 0f,
|
||||||
|
targetValue = 360f,
|
||||||
|
animationSpec = infiniteRepeatable(
|
||||||
|
animation = tween(1000, easing = LinearEasing),
|
||||||
|
repeatMode = RepeatMode.Restart
|
||||||
|
),
|
||||||
|
label = "spinner_rotation"
|
||||||
|
)
|
||||||
|
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Refresh,
|
||||||
|
contentDescription = "Creating payment",
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier
|
||||||
|
.size(24.dp)
|
||||||
|
.graphicsLayer { rotationZ = rotation }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PaymentStatus.Success -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.CheckCircle,
|
||||||
|
contentDescription = "Payment sent",
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PaymentStatus.Error -> {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Filled.Error,
|
||||||
|
contentDescription = "Payment failed",
|
||||||
|
tint = color,
|
||||||
|
modifier = Modifier.size(24.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentStatusText(
|
||||||
|
status: PaymentStatus,
|
||||||
|
textColor: Color
|
||||||
|
) {
|
||||||
|
when (status) {
|
||||||
|
is PaymentStatus.Creating -> {
|
||||||
|
Text(
|
||||||
|
text = "Creating payment...",
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Medium,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Generating Cashu token",
|
||||||
|
color = textColor.copy(alpha = 0.7f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PaymentStatus.Success -> {
|
||||||
|
Text(
|
||||||
|
text = "Payment sent!",
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = "Cashu token delivered to chat",
|
||||||
|
color = textColor.copy(alpha = 0.8f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
is PaymentStatus.Error -> {
|
||||||
|
Text(
|
||||||
|
text = "Payment failed",
|
||||||
|
color = textColor,
|
||||||
|
fontSize = 14.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = status.message,
|
||||||
|
color = textColor.copy(alpha = 0.8f),
|
||||||
|
fontSize = 12.sp,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentAmountDisplay(
|
||||||
|
amount: Long,
|
||||||
|
color: Color
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
horizontalAlignment = Alignment.End
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "⚡",
|
||||||
|
fontSize = 16.sp,
|
||||||
|
color = color
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = formatSats(amount),
|
||||||
|
color = color,
|
||||||
|
fontSize = 16.sp,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
fontFamily = FontFamily.Monospace
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Colors for different payment states
|
||||||
|
*/
|
||||||
|
private data class PaymentColors(
|
||||||
|
val background: Color,
|
||||||
|
val border: Color,
|
||||||
|
val icon: Color,
|
||||||
|
val text: Color
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format sats amount for display
|
||||||
|
*/
|
||||||
|
private fun formatSats(sats: Long): String {
|
||||||
|
return when {
|
||||||
|
sats >= 100_000_000 -> String.format("%.2f BTC", sats / 100_000_000.0)
|
||||||
|
sats >= 1000 -> String.format("%,d sats", sats)
|
||||||
|
else -> "$sats sats"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -284,8 +284,10 @@ class CashuService {
|
|||||||
|
|
||||||
Log.d(TAG, "Token validation successful, amount: $amount")
|
Log.d(TAG, "Token validation successful, amount: $amount")
|
||||||
|
|
||||||
wallet!!.receive(token)
|
val ffiAmount = wallet!!.receive(token)
|
||||||
|
|
||||||
|
Log.d(TAG, "FFI amount: ${ffiAmount}")
|
||||||
|
|
||||||
Result.success(amount)
|
Result.success(amount)
|
||||||
|
|
||||||
} catch (e: FfiException) {
|
} catch (e: FfiException) {
|
||||||
|
|||||||
@@ -526,6 +526,54 @@ class WalletViewModel(application: Application) : AndroidViewModel(application)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a Cashu token for payments (with callbacks for /pay command)
|
||||||
|
*/
|
||||||
|
fun createCashuTokenForPayment(
|
||||||
|
amount: Long,
|
||||||
|
memo: String? = null,
|
||||||
|
onSuccess: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit
|
||||||
|
) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
cashuService.createToken(amount, memo).onSuccess { token ->
|
||||||
|
|
||||||
|
// Add transaction
|
||||||
|
val transaction = WalletTransaction(
|
||||||
|
id = UUID.randomUUID().toString(),
|
||||||
|
type = TransactionType.CASHU_SEND,
|
||||||
|
amount = BigDecimal(amount),
|
||||||
|
unit = "sat",
|
||||||
|
status = TransactionStatus.CONFIRMED,
|
||||||
|
timestamp = Date(),
|
||||||
|
description = memo ?: "Cashu token sent",
|
||||||
|
token = token
|
||||||
|
)
|
||||||
|
|
||||||
|
repository.saveTransaction(transaction).onSuccess {
|
||||||
|
loadTransactions()
|
||||||
|
refreshBalance()
|
||||||
|
|
||||||
|
// Success callback with token
|
||||||
|
onSuccess(token)
|
||||||
|
|
||||||
|
}.onFailure { error ->
|
||||||
|
Log.e(TAG, "Failed to save transaction", error)
|
||||||
|
onError("Failed to save transaction: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
}.onFailure { error ->
|
||||||
|
onError("Failed to create token: ${error.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Exception creating payment token", e)
|
||||||
|
onError(e.message ?: "Unknown error occurred")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Decode a Cashu token to show information
|
* Decode a Cashu token to show information
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user