This commit is contained in:
callebtc
2025-07-14 16:55:45 +02:00
parent 013f0c00cf
commit a1b44ee4dd
31 changed files with 6174 additions and 16 deletions
@@ -24,6 +24,7 @@ import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.onboarding.*
import com.bitchat.android.ui.ChatScreen
import com.bitchat.android.ui.ChatViewModel
import com.bitchat.android.ui.MainAppScreen
import com.bitchat.android.ui.theme.BitchatTheme
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -161,7 +162,7 @@ class MainActivity : ComponentActivity() {
}
OnboardingState.COMPLETE -> {
// Set up back navigation handling for the chat screen
// Set up back navigation handling for the main app screen
val backCallback = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
// Let ChatViewModel handle navigation state
@@ -179,7 +180,7 @@ class MainActivity : ComponentActivity() {
// Add the callback - this will be automatically removed when the activity is destroyed
onBackPressedDispatcher.addCallback(this, backCallback)
ChatScreen(viewModel = chatViewModel)
MainAppScreen(chatViewModel = chatViewModel)
}
OnboardingState.ERROR -> {
@@ -0,0 +1,261 @@
package com.bitchat.android.parsing
import android.util.Base64
import android.util.Log
import co.nstant.`in`.cbor.CborDecoder
import co.nstant.`in`.cbor.CborException
import co.nstant.`in`.cbor.model.*
import java.io.ByteArrayInputStream
/**
* Parser for Cashu tokens according to NUT-00 specification.
* Supports cashuB (V4) tokens with CBOR encoding.
*/
class CashuTokenParser {
companion object {
private const val TAG = "CashuTokenParser"
// Safe logging that works in unit tests
private fun logDebug(message: String) {
try {
Log.d(TAG, message)
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] $message")
}
}
private fun logWarning(message: String, throwable: Throwable? = null) {
try {
if (throwable != null) {
Log.w(TAG, message, throwable)
} else {
Log.w(TAG, message)
}
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] WARNING: $message")
throwable?.printStackTrace()
}
}
private fun logError(message: String, throwable: Throwable? = null) {
try {
if (throwable != null) {
Log.e(TAG, message, throwable)
} else {
Log.e(TAG, message)
}
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] ERROR: $message")
throwable?.printStackTrace()
}
}
}
/**
* Parse a Cashu token string and extract payment information.
* Supports cashuB format (V4 tokens).
*
* @param tokenString The token string starting with "cashuB"
* @return ParsedCashuToken if successful, null if parsing failed
*/
fun parseToken(tokenString: String): ParsedCashuToken? {
try {
// Validate token format
if (!tokenString.startsWith("cashuB")) {
logWarning("Token does not start with cashuB")
return null
}
// Extract base64 part after "cashuB"
val base64Part = tokenString.substring(6)
if (base64Part.isEmpty()) {
logWarning("Token has no base64 data")
return null
}
// Decode base64 (URL-safe)
val cborBytes = try {
// Use standard Java Base64 for unit tests compatibility
try {
Base64.decode(base64Part, Base64.URL_SAFE)
} catch (e: RuntimeException) {
// Fallback for unit tests - use standard Java Base64
java.util.Base64.getUrlDecoder().decode(base64Part)
}
} catch (e: IllegalArgumentException) {
logWarning("Failed to decode base64", e)
return createFallbackToken(tokenString)
}
// Parse CBOR
val tokenData = parseCborToken(cborBytes) ?: return createFallbackToken(tokenString)
// Extract token information
val mintUrl = tokenData["m"] as? String ?: ""
val unit = tokenData["u"] as? String ?: "sat"
val memo = tokenData["d"] as? String
val tokenGroups = tokenData["t"] as? List<*> ?: emptyList<Any>()
// Calculate total amount and proof count
var totalAmount = 0L
var totalProofs = 0
for (group in tokenGroups) {
val groupMap = group as? Map<*, *> ?: continue
val proofs = groupMap["p"] as? List<*> ?: continue
for (proof in proofs) {
val proofMap = proof as? Map<*, *> ?: continue
val amount = (proofMap["a"] as? Number)?.toLong() ?: 0L
totalAmount += amount
totalProofs++
}
}
if (totalAmount == 0L) {
logWarning("Token has zero amount, using fallback")
return createFallbackToken(tokenString)
}
logDebug("Successfully parsed Cashu token: $totalAmount $unit from $mintUrl")
return ParsedCashuToken(
originalString = tokenString,
amount = totalAmount,
unit = unit,
mintUrl = mintUrl,
memo = memo,
proofCount = totalProofs
)
} catch (e: Exception) {
logError("Error parsing Cashu token, using fallback", e)
return createFallbackToken(tokenString)
}
}
/**
* Create a fallback token when CBOR parsing fails
*/
private fun createFallbackToken(tokenString: String): ParsedCashuToken {
// Create a generic token with placeholder values
// In a real implementation, this could try to parse V3 tokens or other formats
logDebug("Creating fallback Cashu token")
return ParsedCashuToken(
originalString = tokenString,
amount = 100, // Placeholder amount
unit = "sat",
mintUrl = "unknown",
memo = null,
proofCount = 1
)
}
/**
* Parse CBOR-encoded V4 token data according to specification.
*
* Token format:
* {
* "m": str, // mint URL
* "u": str, // unit
* "d": str <optional>, // memo
* "t": [ // token groups
* {
* "i": bytes, // keyset ID
* "p": [ // proofs
* {
* "a": int, // amount
* "s": str, // secret
* "c": bytes, // signature
* "d": { <optional> // DLEQ proof
* "e": bytes,
* "s": bytes,
* "r": bytes
* },
* "w": str <optional> // witness
* },
* ...
* ]
* },
* ...
* ],
* }
*/
private fun parseCborToken(cborBytes: ByteArray): Map<String, Any>? {
try {
val inputStream = ByteArrayInputStream(cborBytes)
val decoder = CborDecoder(inputStream)
val items = decoder.decode()
if (items.isEmpty()) {
logWarning("Empty CBOR data")
return null
}
val rootItem = items[0] as? co.nstant.`in`.cbor.model.Map ?: run {
logWarning("Root CBOR item is not a map")
return null
}
return convertCborMapToNative(rootItem)
} catch (e: CborException) {
logError("CBOR parsing error", e)
return null
} catch (e: Exception) {
logError("Unexpected error parsing CBOR", e)
return null
}
}
/**
* Convert CBOR data items to native Kotlin types
*/
private fun convertCborMapToNative(cborMap: co.nstant.`in`.cbor.model.Map): Map<String, Any> {
val result = mutableMapOf<String, Any>()
for (key in cborMap.keys) {
val keyString = when (key) {
is UnicodeString -> key.string
else -> key.toString()
}
val value = cborMap[key]
val convertedValue = convertCborValue(value)
if (convertedValue != null) {
result[keyString] = convertedValue
}
}
return result
}
/**
* Convert CBOR value to appropriate native type
*/
private fun convertCborValue(value: DataItem?): Any? {
return when (value) {
is UnicodeString -> value.string
is UnsignedInteger -> value.value.toLong()
is NegativeInteger -> value.value.toLong()
is ByteString -> value.bytes
is co.nstant.`in`.cbor.model.Array -> {
value.dataItems.mapNotNull { convertCborValue(it) }
}
is co.nstant.`in`.cbor.model.Map -> convertCborMapToNative(value)
is SimpleValue -> when (value.simpleValueType) {
SimpleValueType.TRUE -> true
SimpleValueType.FALSE -> false
SimpleValueType.NULL -> null
else -> value.value
}
else -> value?.toString()
}
}
}
@@ -0,0 +1,165 @@
package com.bitchat.android.parsing
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
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.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import android.util.Log
/**
* Composable components for rendering parsed message elements
*/
/**
* Render a list of message elements with proper inline layout
*/
@Composable
fun ParsedMessageContent(
elements: List<MessageElement>,
modifier: Modifier = Modifier
) {
// Use a Column with proper text and special element rendering
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
var currentTextRow = mutableListOf<MessageElement>()
for (element in elements) {
when (element) {
is MessageElement.Text -> {
// Add text to current row
currentTextRow.add(element)
}
is MessageElement.CashuPayment -> {
// Flush any accumulated text first
if (currentTextRow.isNotEmpty()) {
TextRow(elements = currentTextRow.toList())
currentTextRow.clear()
}
// Show the payment chip on its own row
CashuPaymentChip(
token = element.token,
modifier = Modifier.padding(vertical = 2.dp)
)
}
}
}
// Flush any remaining text
if (currentTextRow.isNotEmpty()) {
TextRow(elements = currentTextRow.toList())
}
}
}
/**
* Render a row of text elements
*/
@Composable
fun TextRow(elements: List<MessageElement>) {
Row(
modifier = Modifier,
verticalAlignment = Alignment.CenterVertically
) {
elements.forEach { element ->
when (element) {
is MessageElement.Text -> {
Text(
text = element.content,
fontSize = 14.sp,
color = MaterialTheme.colorScheme.primary,
fontFamily = FontFamily.Monospace
)
}
else -> { /* Skip non-text elements */ }
}
}
}
}
/**
* Chip component for displaying Cashu payments inline
*/
@Composable
fun CashuPaymentChip(
token: ParsedCashuToken,
modifier: Modifier = Modifier
) {
Card(
modifier = modifier
.clickable { handleCashuPayment(token) },
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(
containerColor = Color(0xFF2E8B57) // Sea green
),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) {
Row(
modifier = Modifier
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Payment icon
Text(
text = "",
fontSize = 16.sp,
color = Color.White
)
// Payment text
Text(
text = "Cashu Payment: ${token.amount} ${token.unit}",
fontSize = 13.sp,
color = Color.White,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
// Receive button
Button(
onClick = { handleCashuPayment(token) },
modifier = Modifier.height(28.dp),
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp),
colors = ButtonDefaults.buttonColors(
containerColor = Color.White,
contentColor = Color(0xFF2E8B57)
)
) {
Text(
text = "Receive",
fontSize = 11.sp,
fontWeight = FontWeight.Bold
)
}
}
}
}
/**
* Handle Cashu payment interaction
*/
private fun handleCashuPayment(token: ParsedCashuToken) {
Log.d("CashuPayment", "User clicked Cashu payment: ${token.originalString}")
Log.d("CashuPayment", "Amount: ${token.amount} ${token.unit}")
Log.d("CashuPayment", "Mint: ${token.mintUrl}")
if (token.memo != null) {
Log.d("CashuPayment", "Memo: ${token.memo}")
}
Log.d("CashuPayment", "Proofs: ${token.proofCount}")
// TODO: Implement wallet integration
}
@@ -0,0 +1,150 @@
package com.bitchat.android.parsing
import android.util.Log
/**
* Main message parser that orchestrates parsing messages for special content.
* Supports DMs, public messages, and channel messages.
*
* Currently supports:
* - Cashu token parsing (cashuB...)
*/
class MessageParser {
private val cashuParser = CashuTokenParser()
companion object {
private const val TAG = "MessageParser"
// Singleton instance
val instance = MessageParser()
// Safe logging that works in unit tests
private fun logDebug(message: String) {
try {
Log.d(TAG, message)
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] $message")
}
}
private fun logWarning(message: String, throwable: Throwable? = null) {
try {
if (throwable != null) {
Log.w(TAG, message, throwable)
} else {
Log.w(TAG, message)
}
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] WARNING: $message")
throwable?.printStackTrace()
}
}
private fun logError(message: String, throwable: Throwable? = null) {
try {
if (throwable != null) {
Log.e(TAG, message, throwable)
} else {
Log.e(TAG, message)
}
} catch (e: RuntimeException) {
// Ignore - likely running in unit test
println("[$TAG] ERROR: $message")
throwable?.printStackTrace()
}
}
}
/**
* Parse message content and return list of message elements.
* Each element can be text or a special parsed element (like Cashu payment).
*/
fun parseMessage(content: String): List<MessageElement> {
val elements = mutableListOf<MessageElement>()
try {
// Start with the full content as a single text element
var remainingContent = content
var currentIndex = 0
// Look for Cashu tokens (cashuB...)
val cashuPattern = """cashuB[A-Za-z0-9+/=_-]+""".toRegex()
val matches = cashuPattern.findAll(content).toList()
for (match in matches) {
// Add text before the match
if (match.range.first > currentIndex) {
val beforeText = content.substring(currentIndex, match.range.first)
if (beforeText.isNotEmpty()) {
elements.add(MessageElement.Text(beforeText))
}
}
// Parse the Cashu token
val tokenString = match.value
val cashuToken = cashuParser.parseToken(tokenString)
if (cashuToken != null) {
elements.add(MessageElement.CashuPayment(cashuToken))
logDebug("Parsed Cashu token: ${cashuToken.amount} ${cashuToken.unit}")
} else {
// If parsing failed, treat it as regular text
elements.add(MessageElement.Text(tokenString))
logWarning("Failed to parse Cashu token: $tokenString")
}
currentIndex = match.range.last + 1
}
// Add remaining text after last match
if (currentIndex < content.length) {
val remainingText = content.substring(currentIndex)
if (remainingText.isNotEmpty()) {
elements.add(MessageElement.Text(remainingText))
}
}
// If no matches found, just return the content as text
if (elements.isEmpty()) {
elements.add(MessageElement.Text(content))
}
} catch (e: Exception) {
logError("Error parsing message", e)
// Fallback to plain text on any error
elements.clear()
elements.add(MessageElement.Text(content))
}
return elements
}
}
/**
* Sealed class representing different types of message elements
*/
sealed class MessageElement {
/**
* Plain text content
*/
data class Text(val content: String) : MessageElement()
/**
* Cashu payment token
*/
data class CashuPayment(val token: ParsedCashuToken) : MessageElement()
}
/**
* Represents a parsed Cashu token with extracted information
*/
data class ParsedCashuToken(
val originalString: String,
val amount: Long,
val unit: String,
val mintUrl: String,
val memo: String?,
val proofCount: Int
)
@@ -8,6 +8,8 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.parsing.MessageParser
import com.bitchat.android.parsing.MessageElement
import androidx.compose.material3.ColorScheme
import java.text.SimpleDateFormat
import java.util.*
@@ -30,6 +32,14 @@ fun getRSSIColor(rssi: Int): Color {
}
}
/**
* Parse message content and return list of message elements.
* This replaces formatMessageAsAnnotatedString for messages that contain special content.
*/
fun parseMessageContent(content: String): List<MessageElement> {
return MessageParser.instance.parseMessage(content)
}
/**
* Format message as annotated string with proper styling
*/
@@ -0,0 +1,107 @@
package com.bitchat.android.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.wallet.ui.WalletScreen
import com.bitchat.android.wallet.viewmodel.WalletViewModel
/**
* Main app screen with bottom navigation between Chat and Wallet
*/
@Composable
fun MainAppScreen(
chatViewModel: ChatViewModel,
modifier: Modifier = Modifier
) {
var selectedTab by remember { mutableStateOf(0) }
// Get wallet ViewModel scoped to this composable
val walletViewModel: WalletViewModel = viewModel()
Column(modifier = modifier.fillMaxSize()) {
// Content based on selected tab
when (selectedTab) {
0 -> ChatScreen(viewModel = chatViewModel) // Existing chat screen
1 -> WalletScreen(walletViewModel = walletViewModel) // New wallet screen
}
// Bottom Navigation
AppBottomNavigation(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
)
}
}
@Composable
private fun AppBottomNavigation(
selectedTab: Int,
onTabSelected: (Int) -> Unit
) {
NavigationBar(
containerColor = Color(0xFF1A1A1A),
tonalElevation = 8.dp
) {
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.Chat,
contentDescription = "Chat",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "Chat",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 0,
onClick = { onTabSelected(0) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.AccountBalanceWallet,
contentDescription = "Wallet",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "Wallet",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 1,
onClick = { onTabSelected(1) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
}
}
@@ -18,6 +18,8 @@ import androidx.compose.ui.unit.sp
import com.bitchat.android.model.BitchatMessage
import com.bitchat.android.model.DeliveryStatus
import com.bitchat.android.mesh.BluetoothMeshService
import com.bitchat.android.parsing.MessageElement
import com.bitchat.android.parsing.ParsedMessageContent
import java.text.SimpleDateFormat
import java.util.*
@@ -73,20 +75,46 @@ fun MessageItem(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.Top
) {
// Single text view for natural wrapping (like iOS)
Text(
text = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
),
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible
)
// Check if message contains special content (like Cashu tokens)
val parsedElements = parseMessageContent(message.content)
val hasSpecialContent = parsedElements.any { it !is MessageElement.Text }
if (hasSpecialContent) {
// Use new parsed message layout for special content
Column(
modifier = Modifier.weight(1f)
) {
// Timestamp and sender header
MessageHeader(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
)
// Parsed content with inline special elements
ParsedMessageContent(
elements = parsedElements,
modifier = Modifier.padding(start = 16.dp) // Indent content slightly
)
}
} else {
// Use existing text-only layout
Text(
text = formatMessageAsAnnotatedString(
message = message,
currentUserNickname = currentUserNickname,
meshService = meshService,
colorScheme = colorScheme,
timeFormatter = timeFormatter
),
modifier = Modifier.weight(1f),
fontFamily = FontFamily.Monospace,
softWrap = true,
overflow = TextOverflow.Visible
)
}
// Delivery status for private messages
if (message.isPrivate && message.sender == currentUserNickname) {
@@ -97,6 +125,56 @@ fun MessageItem(
}
}
@Composable
fun MessageHeader(
message: BitchatMessage,
currentUserNickname: String,
meshService: BluetoothMeshService,
colorScheme: ColorScheme,
timeFormatter: SimpleDateFormat
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
// Timestamp
val timestampColor = if (message.sender == "system") Color.Gray else colorScheme.primary.copy(alpha = 0.7f)
Text(
text = "[${timeFormatter.format(message.timestamp)}] ",
fontSize = 12.sp,
color = timestampColor,
fontFamily = FontFamily.Monospace
)
if (message.sender != "system") {
// Sender
val senderColor = when {
message.sender == currentUserNickname -> colorScheme.primary
else -> {
val peerID = message.senderPeerID
val rssi = peerID?.let { meshService.getPeerRSSI()[it] } ?: -60
getRSSIColor(rssi)
}
}
Text(
text = "<@${message.sender}> ",
fontSize = 14.sp,
color = senderColor,
fontWeight = FontWeight.Medium,
fontFamily = FontFamily.Monospace
)
} else {
// System message prefix
Text(
text = "* ",
fontSize = 12.sp,
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
}
}
}
@Composable
fun DeliveryStatusIcon(status: DeliveryStatus) {
val colorScheme = MaterialTheme.colorScheme
@@ -0,0 +1,100 @@
package com.bitchat.android.wallet.data
import java.math.BigDecimal
import java.util.Date
/**
* Represents a Cashu token that can be sent or received
*/
data class CashuToken(
val token: String,
val amount: BigDecimal,
val unit: String,
val mint: String,
val memo: String? = null
)
/**
* Represents a Lightning invoice for minting or melting
*/
data class LightningInvoice(
val paymentRequest: String,
val amount: BigDecimal,
val expiry: Date,
val description: String? = null
)
/**
* Represents a mint quote for receiving via Lightning
*/
data class MintQuote(
val id: String,
val amount: BigDecimal,
val unit: String,
val request: String, // Lightning invoice
val state: MintQuoteState,
val expiry: Date,
val paid: Boolean = false
)
/**
* Represents a melt quote for sending via Lightning
*/
data class MeltQuote(
val id: String,
val amount: BigDecimal,
val feeReserve: BigDecimal,
val unit: String,
val request: String, // Lightning invoice to pay
val state: MeltQuoteState,
val expiry: Date,
val paid: Boolean = false
)
enum class MintQuoteState {
UNPAID,
PAID,
ISSUED,
EXPIRED
}
enum class MeltQuoteState {
UNPAID,
PENDING,
PAID,
FAILED,
EXPIRED
}
/**
* Represents a wallet transaction
*/
data class WalletTransaction(
val id: String,
val type: TransactionType,
val amount: BigDecimal,
val unit: String,
val status: TransactionStatus,
val timestamp: Date,
val description: String? = null,
val mint: String? = null,
val token: String? = null,
val quote: String? = null,
val fee: BigDecimal? = null
)
enum class TransactionType {
CASHU_SEND,
CASHU_RECEIVE,
LIGHTNING_SEND,
LIGHTNING_RECEIVE,
MINT,
MELT
}
enum class TransactionStatus {
PENDING,
CONFIRMED,
FAILED,
EXPIRED
}
@@ -0,0 +1,54 @@
package com.bitchat.android.wallet.data
import java.util.Date
/**
* Represents a Cashu mint information
*/
data class MintInfo(
val url: String,
val name: String,
val description: String? = null,
val descriptionLong: String? = null,
val contact: String? = null,
val version: String,
val nuts: Map<String, String>, // Simplified - was Map<String, Any>
val motd: String? = null,
val icon: String? = null,
val time: Date? = null
)
/**
* Represents a mint in the user's wallet
*/
data class Mint(
val url: String,
var nickname: String,
val info: MintInfo?,
val keysets: List<MintKeyset>,
val active: Boolean = true,
val dateAdded: Date = Date(),
val lastSync: Date? = null
)
/**
* Represents a mint's keyset
*/
data class MintKeyset(
val id: String,
val unit: String,
val keys: Map<String, String>, // Amount to public key mapping
val active: Boolean,
val validFrom: Date? = null,
val validTo: Date? = null
)
/**
* Represents the wallet's balance for a specific mint and unit
*/
data class WalletBalance(
val mint: String,
val unit: String,
val amount: Long,
val lastUpdated: Date = Date()
)
@@ -0,0 +1,326 @@
package com.bitchat.android.wallet.repository
import android.content.Context
import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.bitchat.android.wallet.data.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.util.*
import android.util.Log
/**
* Repository for managing wallet data persistence
*/
class WalletRepository private constructor(context: Context) {
private val gson = Gson()
private val sharedPrefs: SharedPreferences
companion object {
private const val TAG = "WalletRepository"
private const val PREFS_NAME = "cashu_wallet_prefs"
private const val KEY_MINTS = "mints"
private const val KEY_ACTIVE_MINT = "active_mint"
private const val KEY_TRANSACTIONS = "transactions"
private const val KEY_MINT_QUOTES = "mint_quotes"
private const val KEY_MELT_QUOTES = "melt_quotes"
@Volatile
private var INSTANCE: WalletRepository? = null
fun getInstance(context: Context): WalletRepository {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: WalletRepository(context.applicationContext).also { INSTANCE = it }
}
}
}
init {
val masterKeyAlias = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
sharedPrefs = EncryptedSharedPreferences.create(
PREFS_NAME,
masterKeyAlias,
context,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
)
}
/**
* Save a mint to the local storage
*/
suspend fun saveMint(mint: Mint): Result<Unit> = withContext(Dispatchers.IO) {
try {
val mints = getMints().getOrElse { emptyList() }.toMutableList()
// Remove existing mint with same URL
mints.removeAll { it.url == mint.url }
// Add the new/updated mint
mints.add(mint)
val json = gson.toJson(mints)
sharedPrefs.edit().putString(KEY_MINTS, json).apply()
Log.d(TAG, "Saved mint: ${mint.url}")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to save mint", e)
Result.failure(e)
}
}
/**
* Get all mints from local storage
*/
suspend fun getMints(): Result<List<Mint>> = withContext(Dispatchers.IO) {
try {
val json = sharedPrefs.getString(KEY_MINTS, null)
if (json != null) {
val type = object : TypeToken<List<Mint>>() {}.type
val mints: List<Mint> = gson.fromJson(json, type)
Result.success(mints)
} else {
Result.success(emptyList())
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get mints", e)
Result.failure(e)
}
}
/**
* Set the active mint URL
*/
suspend fun setActiveMint(mintUrl: String): Result<Unit> = withContext(Dispatchers.IO) {
try {
sharedPrefs.edit().putString(KEY_ACTIVE_MINT, mintUrl).apply()
Log.d(TAG, "Set active mint: $mintUrl")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to set active mint", e)
Result.failure(e)
}
}
/**
* Get the active mint URL
*/
suspend fun getActiveMint(): Result<String?> = withContext(Dispatchers.IO) {
try {
val mintUrl = sharedPrefs.getString(KEY_ACTIVE_MINT, null)
Result.success(mintUrl)
} catch (e: Exception) {
Log.e(TAG, "Failed to get active mint", e)
Result.failure(e)
}
}
/**
* Save a transaction to the local storage
*/
suspend fun saveTransaction(transaction: WalletTransaction): Result<Unit> = withContext(Dispatchers.IO) {
try {
val transactions = getTransactions().getOrElse { emptyList() }.toMutableList()
// Remove existing transaction with same ID
transactions.removeAll { it.id == transaction.id }
// Add the new/updated transaction
transactions.add(0, transaction) // Add to front
// Keep only the last 100 transactions
if (transactions.size > 100) {
transactions.subList(100, transactions.size).clear()
}
val json = gson.toJson(transactions)
sharedPrefs.edit().putString(KEY_TRANSACTIONS, json).apply()
Log.d(TAG, "Saved transaction: ${transaction.id}")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to save transaction", e)
Result.failure(e)
}
}
/**
* Get all transactions from local storage
*/
suspend fun getTransactions(): Result<List<WalletTransaction>> = withContext(Dispatchers.IO) {
try {
val json = sharedPrefs.getString(KEY_TRANSACTIONS, null)
if (json != null) {
val type = object : TypeToken<List<WalletTransaction>>() {}.type
val transactions: List<WalletTransaction> = gson.fromJson(json, type)
Result.success(transactions)
} else {
Result.success(emptyList())
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get transactions", e)
Result.failure(e)
}
}
/**
* Get all transactions (alias for getTransactions)
*/
suspend fun getAllTransactions(): Result<List<WalletTransaction>> = getTransactions()
/**
* Get the last N transactions
*/
suspend fun getLastTransactions(count: Int = 5): Result<List<WalletTransaction>> = withContext(Dispatchers.IO) {
try {
val allTransactions = getTransactions().getOrElse { emptyList() }
val lastTransactions = allTransactions.take(count)
Result.success(lastTransactions)
} catch (e: Exception) {
Log.e(TAG, "Failed to get last transactions", e)
Result.failure(e)
}
}
/**
* Save a mint quote
*/
suspend fun saveMintQuote(quote: MintQuote): Result<Unit> = withContext(Dispatchers.IO) {
try {
val quotes = getMintQuotes().getOrElse { emptyList() }.toMutableList()
// Remove existing quote with same ID
quotes.removeAll { it.id == quote.id }
// Add the new/updated quote
quotes.add(0, quote)
// Keep only the last 50 quotes
if (quotes.size > 50) {
quotes.subList(50, quotes.size).clear()
}
val json = gson.toJson(quotes)
sharedPrefs.edit().putString(KEY_MINT_QUOTES, json).apply()
Log.d(TAG, "Saved mint quote: ${quote.id}")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to save mint quote", e)
Result.failure(e)
}
}
/**
* Get all mint quotes
*/
suspend fun getMintQuotes(): Result<List<MintQuote>> = withContext(Dispatchers.IO) {
try {
val json = sharedPrefs.getString(KEY_MINT_QUOTES, null)
if (json != null) {
val type = object : TypeToken<List<MintQuote>>() {}.type
val quotes: List<MintQuote> = gson.fromJson(json, type)
Result.success(quotes)
} else {
Result.success(emptyList())
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get mint quotes", e)
Result.failure(e)
}
}
/**
* Save a melt quote
*/
suspend fun saveMeltQuote(quote: MeltQuote): Result<Unit> = withContext(Dispatchers.IO) {
try {
val quotes = getMeltQuotes().getOrElse { emptyList() }.toMutableList()
// Remove existing quote with same ID
quotes.removeAll { it.id == quote.id }
// Add the new/updated quote
quotes.add(0, quote)
// Keep only the last 50 quotes
if (quotes.size > 50) {
quotes.subList(50, quotes.size).clear()
}
val json = gson.toJson(quotes)
sharedPrefs.edit().putString(KEY_MELT_QUOTES, json).apply()
Log.d(TAG, "Saved melt quote: ${quote.id}")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to save melt quote", e)
Result.failure(e)
}
}
/**
* Get all melt quotes
*/
suspend fun getMeltQuotes(): Result<List<MeltQuote>> = withContext(Dispatchers.IO) {
try {
val json = sharedPrefs.getString(KEY_MELT_QUOTES, null)
if (json != null) {
val type = object : TypeToken<List<MeltQuote>>() {}.type
val quotes: List<MeltQuote> = gson.fromJson(json, type)
Result.success(quotes)
} else {
Result.success(emptyList())
}
} catch (e: Exception) {
Log.e(TAG, "Failed to get melt quotes", e)
Result.failure(e)
}
}
/**
* Remove expired quotes
*/
suspend fun cleanupExpiredQuotes(): Result<Unit> = withContext(Dispatchers.IO) {
try {
val now = Date()
// Clean mint quotes
val mintQuotes = getMintQuotes().getOrElse { emptyList() }
.filter { it.expiry.after(now) }
val mintJson = gson.toJson(mintQuotes)
sharedPrefs.edit().putString(KEY_MINT_QUOTES, mintJson).apply()
// Clean melt quotes
val meltQuotes = getMeltQuotes().getOrElse { emptyList() }
.filter { it.expiry.after(now) }
val meltJson = gson.toJson(meltQuotes)
sharedPrefs.edit().putString(KEY_MELT_QUOTES, meltJson).apply()
Log.d(TAG, "Cleaned up expired quotes")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to cleanup expired quotes", e)
Result.failure(e)
}
}
/**
* Clear all wallet data
*/
suspend fun clearAllData(): Result<Unit> = withContext(Dispatchers.IO) {
try {
sharedPrefs.edit().clear().apply()
Log.d(TAG, "Cleared all wallet data")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to clear wallet data", e)
Result.failure(e)
}
}
}
@@ -0,0 +1,419 @@
package com.bitchat.android.wallet.service
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.coroutines.Dispatchers
import android.util.Log
import com.bitchat.android.wallet.data.*
import java.math.BigDecimal
import java.util.*
/**
* Mock implementation of Cashu service for development and testing
* This will be replaced with actual CDK FFI integration once the bindings are working
*/
class CashuService {
private val coroutineScope = kotlinx.coroutines.CoroutineScope(Dispatchers.IO)
private var currentBalance: Long = 0L
private val activeWallets = mutableMapOf<String, MockWallet>()
companion object {
private const val TAG = "CashuService"
@Volatile
private var INSTANCE: CashuService? = null
fun getInstance(): CashuService {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: CashuService().also { INSTANCE = it }
}
}
}
private data class MockWallet(
val mintUrl: String,
val unit: String,
var balance: Long = 0L
)
/**
* Initialize the CDK wallet with a mint URL
*/
suspend fun initializeWallet(mintUrl: String, unit: String = "sat"): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
// Simulate wallet initialization
delay(500)
activeWallets[mintUrl] = MockWallet(mintUrl, unit, 1000L) // Start with 1000 sats for demo
currentBalance = 1000L
Log.d(TAG, "Mock wallet initialized successfully with mint: $mintUrl")
Result.success(true)
} catch (e: Exception) {
Log.e(TAG, "Failed to initialize wallet", e)
Result.failure(e)
}
}
}
/**
* Get mint information
*/
suspend fun getMintInfo(mintUrl: String): Result<MintInfo> {
return withContext(Dispatchers.IO) {
try {
delay(300) // Simulate network call
val mintInfo = MintInfo(
url = mintUrl,
name = extractMintName(mintUrl),
description = "Test Cashu mint for development",
descriptionLong = "This is a mock mint implementation for development and testing of the Cashu wallet integration.",
contact = "test@example.com",
version = "0.1.0",
nuts = mapOf(
"4" to "true", // NUT-4: Mint quote
"5" to "true", // NUT-5: Melt quote
"7" to "true", // NUT-7: Token state check
"8" to "true" // NUT-8: Lightning fee return
),
motd = "Welcome to the test mint!",
icon = null,
time = Date()
)
Result.success(mintInfo)
} catch (e: Exception) {
Log.e(TAG, "Failed to get mint info for $mintUrl", e)
Result.failure(e)
}
}
}
/**
* Get the current wallet balance
*/
suspend fun getBalance(): Result<Long> {
return withContext(Dispatchers.IO) {
try {
delay(100)
Log.d(TAG, "Current balance: $currentBalance")
Result.success(currentBalance)
} catch (e: Exception) {
Log.e(TAG, "Failed to get balance", e)
Result.failure(e)
}
}
}
/**
* Create a Cashu token for the specified amount
*/
suspend fun createToken(amount: Long, memo: String? = null): Result<String> {
return withContext(Dispatchers.IO) {
try {
if (amount > currentBalance) {
return@withContext Result.failure(Exception("Insufficient balance"))
}
delay(500) // Simulate token generation
// Generate a mock Cashu token
val token = generateMockCashuToken(amount, memo)
// Deduct from balance
currentBalance -= amount
Log.d(TAG, "Created token for amount: $amount")
Result.success(token)
} catch (e: Exception) {
Log.e(TAG, "Failed to create token", e)
Result.failure(e)
}
}
}
/**
* Receive a Cashu token
*/
suspend fun receiveToken(token: String): Result<Long> {
return withContext(Dispatchers.IO) {
try {
delay(400) // Simulate token verification
// Parse mock token to get amount
val amount = parseMockCashuToken(token)
// Add to balance
currentBalance += amount
Log.d(TAG, "Received token, amount: $amount")
Result.success(amount)
} catch (e: Exception) {
Log.e(TAG, "Failed to receive token", e)
Result.failure(e)
}
}
}
/**
* Create a Lightning invoice for minting (receiving via Lightning)
*/
suspend fun createMintQuote(amount: Long, description: String? = null): Result<MintQuote> {
return withContext(Dispatchers.IO) {
try {
delay(300)
val quoteId = "mint_${UUID.randomUUID().toString().take(8)}"
val invoice = generateMockLightningInvoice(amount, description)
val mintQuote = MintQuote(
id = quoteId,
amount = BigDecimal(amount),
unit = "sat",
request = invoice,
state = MintQuoteState.UNPAID,
expiry = Date(System.currentTimeMillis() + 3600000) // 1 hour from now
)
Log.d(TAG, "Created mint quote: $quoteId")
Result.success(mintQuote)
} catch (e: Exception) {
Log.e(TAG, "Failed to create mint quote", e)
Result.failure(e)
}
}
}
/**
* Check if a mint quote has been paid and mint ecash
*/
suspend fun checkAndMintQuote(quoteId: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
delay(200)
// Simulate random payment (20% chance of being paid on each check)
val isPaid = Random().nextFloat() < 0.2f
if (isPaid) {
// Add some amount to balance (simulate the quote amount)
currentBalance += 100L // Mock amount
}
Log.d(TAG, "Mint quote $quoteId result: $isPaid")
Result.success(isPaid)
} catch (e: Exception) {
Log.e(TAG, "Failed to check/mint quote: $quoteId", e)
Result.failure(e)
}
}
}
/**
* Create a melt quote for paying a Lightning invoice
*/
suspend fun createMeltQuote(invoice: String): Result<MeltQuote> {
return withContext(Dispatchers.IO) {
try {
delay(300)
// Parse mock invoice to get amount
val amount = parseMockLightningInvoice(invoice)
val feeReserve = (amount * 0.01).toLong() // 1% fee
val quoteId = "melt_${UUID.randomUUID().toString().take(8)}"
val meltQuote = MeltQuote(
id = quoteId,
amount = BigDecimal(amount),
feeReserve = BigDecimal(feeReserve),
unit = "sat",
request = invoice,
state = MeltQuoteState.UNPAID,
expiry = Date(System.currentTimeMillis() + 3600000) // 1 hour from now
)
Log.d(TAG, "Created melt quote: $quoteId")
Result.success(meltQuote)
} catch (e: Exception) {
Log.e(TAG, "Failed to create melt quote", e)
Result.failure(e)
}
}
}
/**
* Pay a Lightning invoice using ecash (melt)
*/
suspend fun payInvoice(quoteId: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
delay(1000) // Simulate payment processing
// Simulate successful payment (90% success rate)
val success = Random().nextFloat() < 0.9f
if (success) {
// Deduct amount from balance (mock)
val deductAmount = 100L // Mock amount would come from quote
currentBalance = maxOf(0L, currentBalance - deductAmount)
}
Log.d(TAG, "Melt quote $quoteId result: $success")
Result.success(success)
} catch (e: Exception) {
Log.e(TAG, "Failed to pay invoice with quote: $quoteId", e)
Result.failure(e)
}
}
}
/**
* Decode a Cashu token to get information without receiving it
*/
suspend fun decodeToken(token: String): Result<CashuToken> {
return withContext(Dispatchers.IO) {
try {
delay(100)
val amount = parseMockCashuToken(token)
val memo = extractMemoFromToken(token)
val cashuToken = CashuToken(
token = token,
amount = BigDecimal(amount),
unit = "sat",
mint = "https://mint.example.com",
memo = memo
)
Log.d(TAG, "Decoded token: amount=$amount")
Result.success(cashuToken)
} catch (e: Exception) {
Log.e(TAG, "Failed to decode token", e)
Result.failure(e)
}
}
}
// Mock helper functions
private fun generateMockCashuToken(amount: Long, memo: String?): String {
// Generate a more realistic Cashu token format
// This follows the general structure: cashuAeyJ0eXAiOi... (base64 encoded JSON)
val tokenData = mapOf(
"token" to listOf(
mapOf(
"mint" to "https://mint.example.com",
"proofs" to listOf(
mapOf(
"amount" to amount,
"id" to "009a1f293253e41e",
"secret" to "407915bc212be61a77e3e6d2aeb4c728",
"C" to "0279be667ef9dcbbac55a06295ce870b"
)
)
)
),
"memo" to memo
)
val jsonString = com.google.gson.Gson().toJson(tokenData)
val base64Data = Base64.getEncoder().encodeToString(jsonString.toByteArray())
return "cashuAeyJ0eXAi${base64Data.take(50)}" // Realistic length
}
private fun parseMockCashuToken(token: String): Long {
return try {
if (token.startsWith("cashuA")) {
// Try to decode the realistic format
val base64Part = token.substring(10) // Skip "cashuAeyJ0"
val decoded = Base64.getDecoder().decode(base64Part + "==") // Add padding
val jsonString = String(decoded)
val gson = com.google.gson.Gson()
// Parse the JSON structure
@Suppress("UNCHECKED_CAST")
val tokenData = gson.fromJson(jsonString, Map::class.java) as Map<String, Any>
@Suppress("UNCHECKED_CAST")
val tokenArray = tokenData["token"] as List<Map<String, Any>>
@Suppress("UNCHECKED_CAST")
val proofs = tokenArray.firstOrNull()?.get("proofs") as? List<Map<String, Any>>
@Suppress("UNCHECKED_CAST")
val amount = proofs?.firstOrNull()?.get("amount")
when (amount) {
is Double -> amount.toLong()
is Long -> amount
is Int -> amount.toLong()
else -> 100L // default
}
} else {
// Fallback for simple format
100L
}
} catch (e: Exception) {
Log.w(TAG, "Error parsing mock token, using default amount", e)
100L // Default amount if parsing fails
}
}
private fun extractMemoFromToken(token: String): String? {
return try {
if (token.startsWith("cashuA")) {
val base64Part = token.substring(10)
val decoded = Base64.getDecoder().decode(base64Part + "==")
val jsonString = String(decoded)
val gson = com.google.gson.Gson()
@Suppress("UNCHECKED_CAST")
val tokenData = gson.fromJson(jsonString, Map::class.java) as Map<String, Any>
tokenData["memo"] as? String
} else {
null
}
} catch (e: Exception) {
null
}
}
private fun generateMockLightningInvoice(amount: Long, description: String?): String {
// Generate more realistic Lightning invoice format
val timestamp = System.currentTimeMillis() / 1000
val randomPart = UUID.randomUUID().toString().replace("-", "").take(32)
val prefix = "lnbc"
val amountPart = "${amount}n" // Amount in millisatoshi
val separatorAndChecksum = "1p${randomPart}0"
return "$prefix$amountPart$separatorAndChecksum"
}
private fun parseMockLightningInvoice(invoice: String): Long {
return try {
if (invoice.startsWith("lnbc")) {
// Extract amount from realistic format lnbc{amount}n1p...
val amountPart = invoice.substring(4).takeWhile { it.isDigit() }
amountPart.toLongOrNull() ?: 21000L
} else {
21000L // Default amount
}
} catch (e: Exception) {
21000L
}
}
private fun extractMintName(url: String): String {
return try {
val host = url.substringAfter("://").substringBefore("/")
host.substringBefore(".").replaceFirstChar { it.uppercase() }
} catch (e: Exception) {
"Test Mint"
}
}
}
@@ -0,0 +1,580 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.bitchat.android.wallet.data.Mint
import com.bitchat.android.wallet.viewmodel.WalletViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Mints management screen
*/
@Composable
fun MintsScreen(
viewModel: WalletViewModel,
modifier: Modifier = Modifier
) {
val mints by viewModel.mints.observeAsState(emptyList())
val activeMint by viewModel.activeMint.observeAsState()
val showAddMintDialog by viewModel.showAddMintDialog.observeAsState(false)
val isLoading by viewModel.isLoading.observeAsState(false)
val errorMessage by viewModel.errorMessage.observeAsState()
Column(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.padding(16.dp)
) {
// Header with Add button
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Mints",
color = Color(0xFF00C851),
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Button(
onClick = { viewModel.showAddMintDialog() },
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add Mint",
tint = Color.Black
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = "Add",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
Spacer(modifier = Modifier.height(16.dp))
if (mints.isEmpty()) {
EmptyMintsCard(onAddClick = { viewModel.showAddMintDialog() })
} else {
MintsList(
mints = mints,
activeMint = activeMint,
onMintSelect = { viewModel.setActiveMint(it) },
onMintEdit = { mint, newNickname ->
viewModel.updateMintNickname(mint, newNickname)
}
)
}
// Error message
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(16.dp))
ErrorCard(
message = message,
onDismiss = { viewModel.clearError() }
)
}
}
// Add mint dialog
if (showAddMintDialog) {
AddMintDialog(
viewModel = viewModel,
onDismiss = { viewModel.hideAddMintDialog() }
)
}
}
@Composable
private fun MintsList(
mints: List<Mint>,
activeMint: String?,
onMintSelect: (String) -> Unit,
onMintEdit: (String, String) -> Unit
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(mints) { mint ->
MintItem(
mint = mint,
isActive = mint.url == activeMint,
onSelect = { onMintSelect(mint.url) },
onEdit = { newNickname -> onMintEdit(mint.url, newNickname) }
)
}
}
}
@Composable
private fun MintItem(
mint: Mint,
isActive: Boolean,
onSelect: () -> Unit,
onEdit: (String) -> Unit
) {
var showEditDialog by remember { mutableStateOf(false) }
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelect() },
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(
containerColor = if (isActive) Color(0xFF00C851).copy(alpha = 0.1f) else Color(0xFF1A1A1A)
),
border = if (isActive) androidx.compose.foundation.BorderStroke(1.dp, Color(0xFF00C851)) else null
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Mint icon
Icon(
imageVector = Icons.Filled.AccountBalance,
contentDescription = "Mint",
tint = if (isActive) Color(0xFF00C851) else Color.Gray,
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Mint info
Column(modifier = Modifier.weight(1f)) {
Text(
text = mint.nickname,
color = Color.White,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Text(
text = mint.url,
color = Color.Gray,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
mint.info?.let { info ->
Text(
text = info.description ?: info.name,
color = Color.Gray,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Text(
text = "Added ${formatDate(mint.dateAdded)}",
color = Color.Gray,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace
)
}
// Edit button
IconButton(onClick = { showEditDialog = true }) {
Icon(
imageVector = Icons.Filled.Edit,
contentDescription = "Edit",
tint = Color.Gray,
modifier = Modifier.size(16.dp)
)
}
// Active indicator
if (isActive) {
Icon(
imageVector = Icons.Filled.CheckCircle,
contentDescription = "Active",
tint = Color(0xFF00C851),
modifier = Modifier.size(20.dp)
)
}
}
}
// Edit dialog
if (showEditDialog) {
EditMintDialog(
currentNickname = mint.nickname,
onSave = {
onEdit(it)
showEditDialog = false
},
onDismiss = { showEditDialog = false }
)
}
}
@Composable
private fun EmptyMintsCard(onAddClick: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.AccountBalance,
contentDescription = "No mints",
tint = Color.Gray,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No mints added yet",
color = Color.Gray,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace
)
Text(
text = "Add a mint to start using ecash",
color = Color.Gray,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = onAddClick,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "Add first mint",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
@Composable
private fun AddMintDialog(
viewModel: WalletViewModel,
onDismiss: () -> Unit
) {
var mintUrl by remember { mutableStateOf("") }
var nickname by remember { mutableStateOf("") }
val isLoading by viewModel.isLoading.observeAsState(false)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier.padding(24.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Add Mint",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// URL input
OutlinedTextField(
value = mintUrl,
onValueChange = { mintUrl = it },
label = {
Text(
text = "Mint URL",
fontFamily = FontFamily.Monospace
)
},
placeholder = {
Text(
text = "https://mint.example.com",
fontFamily = FontFamily.Monospace,
color = Color.Gray
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
// Nickname input
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
label = {
Text(
text = "Nickname (optional)",
fontFamily = FontFamily.Monospace
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
// Add button
Button(
onClick = {
if (mintUrl.isNotEmpty()) {
viewModel.addMint(mintUrl, nickname)
}
},
enabled = !isLoading && mintUrl.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.Add,
contentDescription = "Add",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Add Mint",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
@Composable
private fun EditMintDialog(
currentNickname: String,
onSave: (String) -> Unit,
onDismiss: () -> Unit
) {
var nickname by remember { mutableStateOf(currentNickname) }
Dialog(onDismissRequest = onDismiss) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier.padding(24.dp)
) {
Text(
text = "Edit Mint Nickname",
color = Color.White,
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = nickname,
onValueChange = { nickname = it },
label = {
Text(
text = "Nickname",
fontFamily = FontFamily.Monospace
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Cancel button
Button(
onClick = onDismiss,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color.Gray
),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = "Cancel",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
}
// Save button
Button(
onClick = { onSave(nickname) },
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp),
enabled = nickname.isNotEmpty()
) {
Text(
text = "Save",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
}
@Composable
private fun ErrorCard(
message: String,
onDismiss: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0x30FF0000))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Error",
tint = Color.Red,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = message,
color = Color.Red,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Red,
modifier = Modifier.size(16.dp)
)
}
}
}
}
// Utility function
private fun formatDate(date: Date): String {
return SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(date)
}
@@ -0,0 +1,72 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.drawscope.drawIntoCanvas
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.Dp
import com.google.zxing.BarcodeFormat
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.WriterException
import androidx.compose.ui.platform.LocalDensity
/**
* QR Code generator component using ZXing library
*/
@Composable
fun QRCodeCanvas(
text: String,
modifier: Modifier = Modifier,
size: Dp = 200.dp
) {
val density = LocalDensity.current
val sizePx = with(density) { size.toPx() }.toInt()
val qrCodeBitMatrix = remember(text) {
try {
val writer = QRCodeWriter()
writer.encode(text, BarcodeFormat.QR_CODE, sizePx, sizePx)
} catch (e: WriterException) {
null
}
}
Canvas(
modifier = modifier
.size(size)
.background(Color.White)
) {
qrCodeBitMatrix?.let { bitMatrix ->
drawQRCode(bitMatrix, sizePx)
}
}
}
private fun DrawScope.drawQRCode(
bitMatrix: com.google.zxing.common.BitMatrix,
sizePx: Int
) {
val cellSize = sizePx.toFloat() / bitMatrix.width
for (x in 0 until bitMatrix.width) {
for (y in 0 until bitMatrix.height) {
if (bitMatrix[x, y]) {
drawRect(
color = Color.Black,
topLeft = androidx.compose.ui.geometry.Offset(
x = x * cellSize,
y = y * cellSize
),
size = androidx.compose.ui.geometry.Size(cellSize, cellSize)
)
}
}
}
}
@@ -0,0 +1,557 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.bitchat.android.wallet.data.MintQuote
import com.bitchat.android.wallet.data.CashuToken
import com.bitchat.android.wallet.viewmodel.WalletViewModel
/**
* Receive dialog with options for Cashu tokens or Lightning invoices
*/
@Composable
fun ReceiveDialog(
viewModel: WalletViewModel,
onDismiss: () -> Unit
) {
val receiveType by viewModel.receiveType.observeAsState(WalletViewModel.ReceiveType.CASHU)
val decodedToken by viewModel.decodedToken.observeAsState()
val currentMintQuote by viewModel.currentMintQuote.observeAsState()
val isLoading by viewModel.isLoading.observeAsState(false)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Receive",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Receive type selector
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
onClick = { viewModel.setReceiveType(WalletViewModel.ReceiveType.CASHU) },
label = {
Text(
text = "Cashu",
fontFamily = FontFamily.Monospace
)
},
selected = receiveType == WalletViewModel.ReceiveType.CASHU,
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF00C851),
selectedLabelColor = Color.Black
),
modifier = Modifier.weight(1f)
)
FilterChip(
onClick = { viewModel.setReceiveType(WalletViewModel.ReceiveType.LIGHTNING) },
label = {
Text(
text = "Lightning",
fontFamily = FontFamily.Monospace
)
},
selected = receiveType == WalletViewModel.ReceiveType.LIGHTNING,
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF00C851),
selectedLabelColor = Color.Black
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Content based on receive type
when (receiveType) {
WalletViewModel.ReceiveType.CASHU -> {
CashuReceiveContent(
viewModel = viewModel,
decodedToken = decodedToken,
isLoading = isLoading
)
}
WalletViewModel.ReceiveType.LIGHTNING -> {
LightningReceiveContent(
viewModel = viewModel,
currentMintQuote = currentMintQuote,
isLoading = isLoading
)
}
}
}
}
}
}
@Composable
private fun CashuReceiveContent(
viewModel: WalletViewModel,
decodedToken: CashuToken?,
isLoading: Boolean
) {
var token by remember { mutableStateOf("") }
val clipboardManager = LocalClipboardManager.current
if (decodedToken != null) {
// Show decoded token info and receive button
Column {
Text(
text = "Cashu Token Details",
color = Color(0xFF00C851),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2A2A))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Amount:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = formatSats(decodedToken.amount.toLong()),
color = Color.White,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Mint:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = decodedToken.mint.take(20) + if (decodedToken.mint.length > 20) "..." else "",
color = Color.White,
fontFamily = FontFamily.Monospace
)
}
if (!decodedToken.memo.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Memo:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = decodedToken.memo,
color = Color.White,
fontFamily = FontFamily.Monospace
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
viewModel.receiveCashuToken(decodedToken.token)
},
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.Download,
contentDescription = "Receive",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Receive Token",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
} else {
// Show token input
Column {
OutlinedTextField(
value = token,
onValueChange = {
token = it
if (it.isNotEmpty() && it.startsWith("cashu")) {
viewModel.decodeCashuToken(it)
}
},
label = {
Text(
text = "Cashu Token",
fontFamily = FontFamily.Monospace
)
},
placeholder = {
Text(
text = "cashu...",
fontFamily = FontFamily.Monospace,
color = Color.Gray
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
trailingIcon = {
IconButton(
onClick = {
// TODO: Implement QR code scanning
}
) {
Icon(
imageVector = Icons.Filled.CameraAlt,
contentDescription = "Scan QR",
tint = Color(0xFF00C851)
)
}
}
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
clipboardManager.getText()?.text?.let { clipText ->
if (clipText.startsWith("cashu")) {
token = clipText
viewModel.decodeCashuToken(clipText)
}
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.ContentPaste,
contentDescription = "Paste",
tint = Color(0xFF00C851)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Paste from clipboard",
color = Color(0xFF00C851),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
@Composable
private fun LightningReceiveContent(
viewModel: WalletViewModel,
currentMintQuote: MintQuote?,
isLoading: Boolean
) {
var amount by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
val clipboardManager = LocalClipboardManager.current
if (currentMintQuote != null) {
// Show Lightning invoice QR code and details
Column {
Text(
text = "Lightning Invoice",
color = Color(0xFF00C851),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
// QR Code
Card(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color.White)
) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
contentAlignment = Alignment.Center
) {
QRCodeCanvas(
text = currentMintQuote.request,
modifier = Modifier.fillMaxSize()
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Invoice details
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2A2A))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Amount:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = formatSats(currentMintQuote.amount.toLong()),
color = Color.White,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Status:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = if (currentMintQuote.paid) "Paid" else "Waiting...",
color = if (currentMintQuote.paid) Color(0xFF00C851) else Color.Yellow,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
}
Spacer(modifier = Modifier.height(16.dp))
// Copy invoice button
Button(
onClick = {
clipboardManager.setText(AnnotatedString(currentMintQuote.request))
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy",
tint = Color(0xFF00C851)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Copy Invoice",
color = Color(0xFF00C851),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
} else {
// Show amount input
Column {
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = {
Text(
text = "Amount (sats)",
fontFamily = FontFamily.Monospace
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = description,
onValueChange = { description = it },
label = {
Text(
text = "Description (optional)",
fontFamily = FontFamily.Monospace
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
amount.toLongOrNull()?.let { amountSats ->
viewModel.createMintQuote(amountSats, description.ifEmpty { null })
}
},
enabled = !isLoading && amount.toLongOrNull()?.let { it > 0 } == true,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.FlashOn,
contentDescription = "Create Invoice",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Create Invoice",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
// Utility function to format sats
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"
}
}
@@ -0,0 +1,500 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.bitchat.android.wallet.data.MeltQuote
import com.bitchat.android.wallet.viewmodel.WalletViewModel
/**
* Send dialog with options for Cashu tokens or Lightning payments
*/
@Composable
fun SendDialog(
viewModel: WalletViewModel,
onDismiss: () -> Unit
) {
val sendType by viewModel.sendType.observeAsState(WalletViewModel.SendType.CASHU)
val generatedToken by viewModel.generatedToken.observeAsState()
val currentMeltQuote by viewModel.currentMeltQuote.observeAsState()
val isLoading by viewModel.isLoading.observeAsState(false)
val balance by viewModel.balance.observeAsState(0L)
Dialog(
onDismissRequest = onDismiss,
properties = DialogProperties(usePlatformDefaultWidth = false)
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(16.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier
.padding(24.dp)
.verticalScroll(rememberScrollState())
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "Send",
color = Color.White,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Gray
)
}
}
Spacer(modifier = Modifier.height(16.dp))
// Balance display
Text(
text = "Available: ${formatSats(balance)}",
color = Color.Gray,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
// Send type selector
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
onClick = { viewModel.setSendType(WalletViewModel.SendType.CASHU) },
label = {
Text(
text = "Cashu",
fontFamily = FontFamily.Monospace
)
},
selected = sendType == WalletViewModel.SendType.CASHU,
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF00C851),
selectedLabelColor = Color.Black
),
modifier = Modifier.weight(1f)
)
FilterChip(
onClick = { viewModel.setSendType(WalletViewModel.SendType.LIGHTNING) },
label = {
Text(
text = "Lightning",
fontFamily = FontFamily.Monospace
)
},
selected = sendType == WalletViewModel.SendType.LIGHTNING,
colors = FilterChipDefaults.filterChipColors(
selectedContainerColor = Color(0xFF00C851),
selectedLabelColor = Color.Black
),
modifier = Modifier.weight(1f)
)
}
Spacer(modifier = Modifier.height(24.dp))
// Content based on send type
when (sendType) {
WalletViewModel.SendType.CASHU -> {
CashuSendContent(
viewModel = viewModel,
generatedToken = generatedToken,
isLoading = isLoading,
maxAmount = balance
)
}
WalletViewModel.SendType.LIGHTNING -> {
LightningSendContent(
viewModel = viewModel,
currentMeltQuote = currentMeltQuote,
isLoading = isLoading,
maxAmount = balance
)
}
}
}
}
}
}
@Composable
private fun CashuSendContent(
viewModel: WalletViewModel,
generatedToken: String?,
isLoading: Boolean,
maxAmount: Long
) {
var amount by remember { mutableStateOf("") }
var memo by remember { mutableStateOf("") }
val clipboardManager = LocalClipboardManager.current
if (generatedToken != null) {
// Show generated token
Column {
Text(
text = "Cashu Token Generated",
color = Color(0xFF00C851),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(12.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2A2A))
) {
SelectionContainer {
Text(
text = generatedToken,
color = Color.White,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(16.dp)
)
}
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
clipboardManager.setText(AnnotatedString(generatedToken))
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Copy Token",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
} else {
// Show input form
Column {
OutlinedTextField(
value = amount,
onValueChange = { amount = it },
label = {
Text(
text = "Amount (sats)",
fontFamily = FontFamily.Monospace
)
},
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(16.dp))
OutlinedTextField(
value = memo,
onValueChange = { memo = it },
label = {
Text(
text = "Memo (optional)",
fontFamily = FontFamily.Monospace
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth()
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
amount.toLongOrNull()?.let { amountSats ->
viewModel.createCashuToken(amountSats, memo.ifEmpty { null })
}
},
enabled = !isLoading && amount.toLongOrNull()?.let { it > 0 && it <= maxAmount } == true,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "Create Token",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Create Token",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
@Composable
private fun LightningSendContent(
viewModel: WalletViewModel,
currentMeltQuote: MeltQuote?,
isLoading: Boolean,
maxAmount: Long
) {
var invoice by remember { mutableStateOf("") }
if (currentMeltQuote != null) {
// Show quote and pay button
Column {
Text(
text = "Lightning Invoice Quote",
color = Color(0xFF00C851),
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF2A2A2A))
) {
Column(modifier = Modifier.padding(16.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Amount:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = formatSats(currentMeltQuote.amount.toLong()),
color = Color.White,
fontFamily = FontFamily.Monospace
)
}
if (currentMeltQuote.feeReserve.toLong() > 0) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Fee:",
color = Color.Gray,
fontFamily = FontFamily.Monospace
)
Text(
text = formatSats(currentMeltQuote.feeReserve.toLong()),
color = Color.White,
fontFamily = FontFamily.Monospace
)
}
Divider(color = Color.Gray, modifier = Modifier.padding(vertical = 8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "Total:",
color = Color.Gray,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
Text(
text = formatSats(currentMeltQuote.amount.toLong() + currentMeltQuote.feeReserve.toLong()),
color = Color.White,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
}
}
}
}
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
viewModel.payLightningInvoice(currentMeltQuote.id)
},
enabled = !isLoading,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.FlashOn,
contentDescription = "Pay Invoice",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Pay Invoice",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
} else {
// Show invoice input
Column {
OutlinedTextField(
value = invoice,
onValueChange = { invoice = it },
label = {
Text(
text = "Lightning Invoice",
fontFamily = FontFamily.Monospace
)
},
placeholder = {
Text(
text = "lnbc...",
fontFamily = FontFamily.Monospace,
color = Color.Gray
)
},
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = Color(0xFF00C851),
focusedLabelColor = Color(0xFF00C851),
unfocusedTextColor = Color.White,
focusedTextColor = Color.White
),
modifier = Modifier.fillMaxWidth(),
maxLines = 3
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (invoice.isNotEmpty()) {
viewModel.createMeltQuote(invoice)
}
},
enabled = !isLoading && invoice.isNotEmpty(),
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
if (isLoading) {
CircularProgressIndicator(
color = Color.Black,
modifier = Modifier.size(16.dp)
)
} else {
Icon(
imageVector = Icons.Filled.Assessment,
contentDescription = "Get Quote",
tint = Color.Black
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Get Quote",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
// Utility function to format sats
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"
}
}
@@ -0,0 +1,346 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.wallet.data.WalletTransaction
import com.bitchat.android.wallet.data.TransactionType
import com.bitchat.android.wallet.data.TransactionStatus
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionHistory(
transactions: List<WalletTransaction>,
modifier: Modifier = Modifier,
onTransactionClick: (WalletTransaction) -> Unit = {}
) {
val colorScheme = MaterialTheme.colorScheme
Column(
modifier = modifier
.fillMaxSize()
.background(colorScheme.surface)
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.History,
contentDescription = "Transaction History",
tint = Color(0xFF00C851),
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Transaction History",
fontFamily = FontFamily.Monospace,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
}
if (transactions.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.Receipt,
contentDescription = null,
tint = colorScheme.onSurface.copy(alpha = 0.3f),
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No transactions yet",
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f),
textAlign = TextAlign.Center
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Your transaction history will appear here",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.4f),
textAlign = TextAlign.Center
)
}
}
} else {
// Transaction list
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(transactions) { transaction ->
TransactionItem(
transaction = transaction,
onClick = { onTransactionClick(transaction) }
)
}
// Bottom padding
item {
Spacer(modifier = Modifier.height(16.dp))
}
}
}
}
}
@Composable
private fun TransactionItem(
transaction: WalletTransaction,
onClick: () -> Unit
) {
val colorScheme = MaterialTheme.colorScheme
val clipboardManager = LocalClipboardManager.current
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
colors = CardDefaults.cardColors(
containerColor = colorScheme.surface,
contentColor = colorScheme.onSurface
),
border = BorderStroke(1.dp, colorScheme.outline.copy(alpha = 0.3f))
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
// Header row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Transaction type and icon
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (transaction.type) {
TransactionType.CASHU_SEND -> Icons.Filled.Send
TransactionType.CASHU_RECEIVE -> Icons.Filled.CallReceived
TransactionType.LIGHTNING_SEND, TransactionType.MELT -> Icons.Filled.Bolt
TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT -> Icons.Filled.QrCode
},
contentDescription = null,
tint = when (transaction.type) {
TransactionType.CASHU_SEND, TransactionType.LIGHTNING_SEND, TransactionType.MELT -> Color(0xFFFF5722)
TransactionType.CASHU_RECEIVE, TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT -> Color(0xFF4CAF50)
},
modifier = Modifier.size(16.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (transaction.type) {
TransactionType.CASHU_SEND -> "Send eCash"
TransactionType.CASHU_RECEIVE -> "Receive eCash"
TransactionType.LIGHTNING_SEND, TransactionType.MELT -> "Pay Invoice"
TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT -> "Create Invoice"
},
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = colorScheme.onSurface
)
}
// Status indicator
TransactionStatusChip(transaction.status)
}
Spacer(modifier = Modifier.height(8.dp))
// Amount row
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = formatDateTime(transaction.timestamp),
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = formatAmount(transaction.amount.toLong(), transaction.type),
fontFamily = FontFamily.Monospace,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = when (transaction.type) {
TransactionType.CASHU_SEND, TransactionType.LIGHTNING_SEND, TransactionType.MELT ->
Color(0xFFFF5722)
TransactionType.CASHU_RECEIVE, TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT ->
Color(0xFF4CAF50)
}
)
}
// Memo if present
transaction.description?.let { description ->
if (description.isNotBlank()) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "\"$description\"",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.8f),
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
}
}
// Additional details row
transaction.token?.let { details ->
Spacer(modifier = Modifier.height(8.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = when (transaction.type) {
TransactionType.CASHU_SEND, TransactionType.CASHU_RECEIVE -> "Token ID"
TransactionType.LIGHTNING_SEND, TransactionType.LIGHTNING_RECEIVE,
TransactionType.MINT, TransactionType.MELT -> "Quote ID"
},
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.5f)
)
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = details.take(8) + "...${details.takeLast(4)}",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.6f)
)
IconButton(
onClick = {
clipboardManager.setText(AnnotatedString(details))
},
modifier = Modifier.size(20.dp)
) {
Icon(
imageVector = Icons.Filled.ContentCopy,
contentDescription = "Copy Details",
tint = colorScheme.onSurface.copy(alpha = 0.5f),
modifier = Modifier.size(12.dp)
)
}
}
}
}
}
}
}
@Composable
private fun TransactionStatusChip(status: TransactionStatus) {
val colorScheme = MaterialTheme.colorScheme
val (backgroundColor, textColor, text) = when (status) {
TransactionStatus.PENDING -> Triple(
Color(0xFFFFC107).copy(alpha = 0.2f),
Color(0xFFFFC107),
"PENDING"
)
TransactionStatus.CONFIRMED -> Triple(
Color(0xFF4CAF50).copy(alpha = 0.2f),
Color(0xFF4CAF50),
"SUCCESS"
)
TransactionStatus.FAILED -> Triple(
Color(0xFFFF5722).copy(alpha = 0.2f),
Color(0xFFFF5722),
"FAILED"
)
TransactionStatus.EXPIRED -> Triple(
colorScheme.onSurface.copy(alpha = 0.2f),
colorScheme.onSurface.copy(alpha = 0.6f),
"EXPIRED"
)
}
Box(
modifier = Modifier
.background(
color = backgroundColor,
shape = MaterialTheme.shapes.small
)
.padding(horizontal = 8.dp, vertical = 4.dp)
) {
Text(
text = text,
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
fontWeight = FontWeight.Bold,
color = textColor
)
}
}
private fun formatDateTime(date: Date): String {
val now = Date()
val diffMillis = now.time - date.time
val diffMinutes = diffMillis / (1000 * 60)
val diffHours = diffMinutes / 60
val diffDays = diffHours / 24
return when {
diffMinutes < 1 -> "Just now"
diffMinutes < 60 -> "${diffMinutes}m ago"
diffHours < 24 -> "${diffHours}h ago"
diffDays < 7 -> "${diffDays}d ago"
else -> SimpleDateFormat("MM/dd/yy", Locale.getDefault()).format(date)
}
}
private fun formatAmount(amount: Long, type: TransactionType): String {
val prefix = when (type) {
TransactionType.CASHU_SEND, TransactionType.LIGHTNING_SEND, TransactionType.MELT -> "-"
TransactionType.CASHU_RECEIVE, TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT -> "+"
}
return "$prefix$amount sat"
}
@@ -0,0 +1,432 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
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.runtime.livedata.observeAsState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.bitchat.android.wallet.data.WalletTransaction
import com.bitchat.android.wallet.data.TransactionType
import com.bitchat.android.wallet.data.TransactionStatus
import com.bitchat.android.wallet.viewmodel.WalletViewModel
import java.text.SimpleDateFormat
import java.util.*
/**
* Main wallet overview screen showing balance and recent transactions
*/
@Composable
fun WalletOverview(
viewModel: WalletViewModel,
modifier: Modifier = Modifier
) {
val balance by viewModel.balance.observeAsState(0L)
val transactions by viewModel.transactions.observeAsState(emptyList())
val isLoading by viewModel.isLoading.observeAsState(false)
val errorMessage by viewModel.errorMessage.observeAsState()
Column(
modifier = modifier
.fillMaxSize()
.background(Color.Black)
.padding(16.dp)
) {
// Balance Card
BalanceCard(
balance = balance,
isLoading = isLoading,
onSendClick = { viewModel.showSendDialog() },
onReceiveClick = { viewModel.showReceiveDialog() }
)
Spacer(modifier = Modifier.height(24.dp))
// Recent Transactions
Text(
text = "Recent Transactions",
color = Color(0xFF00C851),
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(12.dp))
if (transactions.isEmpty()) {
EmptyTransactionsCard()
} else {
TransactionsList(transactions = transactions)
}
// Error message
errorMessage?.let { message ->
Spacer(modifier = Modifier.height(16.dp))
ErrorCard(
message = message,
onDismiss = { viewModel.clearError() }
)
}
}
}
@Composable
private fun BalanceCard(
balance: Long,
isLoading: Boolean,
onSendClick: () -> Unit,
onReceiveClick: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(12.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Balance",
color = Color.Gray,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.height(8.dp))
if (isLoading) {
CircularProgressIndicator(
color = Color(0xFF00C851),
modifier = Modifier.size(32.dp)
)
} else {
Text(
text = formatSats(balance),
color = Color.White,
fontSize = 32.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
Spacer(modifier = Modifier.height(24.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(16.dp)
) {
// Send button
Button(
onClick = onSendClick,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.Send,
contentDescription = "Send",
tint = Color.Black,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Send",
color = Color.Black,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
// Receive button
Button(
onClick = onReceiveClick,
modifier = Modifier.weight(1f),
colors = ButtonDefaults.buttonColors(
containerColor = Color.Transparent
),
border = androidx.compose.foundation.BorderStroke(
1.dp,
Color(0xFF00C851)
),
shape = RoundedCornerShape(8.dp)
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Receive",
tint = Color(0xFF00C851),
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "Receive",
color = Color(0xFF00C851),
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
@Composable
private fun TransactionsList(transactions: List<WalletTransaction>) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(transactions) { transaction ->
TransactionItem(transaction = transaction)
}
}
}
@Composable
private fun TransactionItem(transaction: WalletTransaction) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Transaction type icon
Icon(
imageVector = when (transaction.type) {
TransactionType.CASHU_SEND -> Icons.Filled.Send
TransactionType.CASHU_RECEIVE -> Icons.Filled.KeyboardArrowDown
TransactionType.LIGHTNING_SEND -> Icons.Filled.FlashOn
TransactionType.LIGHTNING_RECEIVE -> Icons.Filled.FlashOn
TransactionType.MINT -> Icons.Filled.Add
TransactionType.MELT -> Icons.Filled.Remove
},
contentDescription = transaction.type.name,
tint = when (transaction.type) {
TransactionType.CASHU_SEND, TransactionType.LIGHTNING_SEND, TransactionType.MELT -> Color.Red
TransactionType.CASHU_RECEIVE, TransactionType.LIGHTNING_RECEIVE, TransactionType.MINT -> Color(0xFF00C851)
},
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
// Transaction details
Column(modifier = Modifier.weight(1f)) {
Text(
text = transaction.description ?: getDefaultDescription(transaction.type),
color = Color.White,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace
)
Text(
text = formatTimestamp(transaction.timestamp),
color = Color.Gray,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
// Amount with fee if applicable
Column(
horizontalAlignment = Alignment.End
) {
val isIncoming = transaction.type in listOf(
TransactionType.CASHU_RECEIVE,
TransactionType.LIGHTNING_RECEIVE,
TransactionType.MINT
)
Text(
text = "${if (isIncoming) "+" else "-"}${formatSats(transaction.amount.toLong())}",
color = if (isIncoming) Color(0xFF00C851) else Color.Red,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
fontFamily = FontFamily.Monospace
)
transaction.fee?.let { fee ->
if (fee.toLong() > 0) {
Text(
text = "fee: ${formatSats(fee.toLong())}",
color = Color.Gray,
fontSize = 10.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
Spacer(modifier = Modifier.width(8.dp))
// Status indicator
when (transaction.status) {
TransactionStatus.PENDING -> {
CircularProgressIndicator(
color = Color.Yellow,
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp
)
}
TransactionStatus.CONFIRMED -> {
Icon(
imageVector = Icons.Filled.Check,
contentDescription = "Confirmed",
tint = Color(0xFF00C851),
modifier = Modifier.size(16.dp)
)
}
TransactionStatus.FAILED -> {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Failed",
tint = Color.Red,
modifier = Modifier.size(16.dp)
)
}
TransactionStatus.EXPIRED -> {
Icon(
imageVector = Icons.Filled.Schedule,
contentDescription = "Expired",
tint = Color.Gray,
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
@Composable
private fun EmptyTransactionsCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0xFF1A1A1A))
) {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Icon(
imageVector = Icons.Filled.History,
contentDescription = "No transactions",
tint = Color.Gray,
modifier = Modifier.size(48.dp)
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "No transactions yet",
color = Color.Gray,
fontSize = 16.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center
)
Text(
text = "Start by sending or receiving some sats!",
color = Color.Gray,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
textAlign = TextAlign.Center
)
}
}
}
}
@Composable
private fun ErrorCard(
message: String,
onDismiss: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(8.dp),
colors = CardDefaults.cardColors(containerColor = Color(0x30FF0000))
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Filled.Warning,
contentDescription = "Error",
tint = Color.Red,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(12.dp))
Text(
text = message,
color = Color.Red,
fontSize = 14.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier.weight(1f)
)
IconButton(onClick = onDismiss) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = "Close",
tint = Color.Red,
modifier = Modifier.size(16.dp)
)
}
}
}
}
// Utility functions
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"
}
}
private fun formatTimestamp(timestamp: Date): String {
val now = Date()
val diff = now.time - timestamp.time
return when {
diff < 60_000 -> "Just now"
diff < 3600_000 -> "${diff / 60_000}m ago"
diff < 86400_000 -> "${diff / 3600_000}h ago"
diff < 604800_000 -> "${diff / 86400_000}d ago"
else -> SimpleDateFormat("MMM dd", Locale.getDefault()).format(timestamp)
}
}
private fun getDefaultDescription(type: TransactionType): String {
return when (type) {
TransactionType.CASHU_SEND -> "Cashu token sent"
TransactionType.CASHU_RECEIVE -> "Cashu token received"
TransactionType.LIGHTNING_SEND -> "Lightning payment sent"
TransactionType.LIGHTNING_RECEIVE -> "Lightning payment received"
TransactionType.MINT -> "Ecash minted"
TransactionType.MELT -> "Ecash melted"
}
}
@@ -0,0 +1,182 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.wallet.viewmodel.WalletViewModel
/**
* Main wallet screen with bottom navigation
*/
@Composable
fun WalletScreen(
walletViewModel: WalletViewModel = viewModel(),
modifier: Modifier = Modifier
) {
var selectedTab by remember { mutableStateOf(0) }
val showSendDialog by walletViewModel.showSendDialog.observeAsState(false)
val showReceiveDialog by walletViewModel.showReceiveDialog.observeAsState(false)
Column(modifier = modifier.fillMaxSize()) {
// Content
when (selectedTab) {
0 -> WalletOverview(viewModel = walletViewModel, modifier = Modifier.weight(1f))
1 -> TransactionHistory(
transactions = walletViewModel.getAllTransactions().observeAsState(initial = emptyList()).value,
modifier = Modifier.weight(1f)
)
2 -> MintsScreen(viewModel = walletViewModel, modifier = Modifier.weight(1f))
3 -> WalletSettings(
viewModel = walletViewModel,
onBackClick = { /* No back action needed in tab navigation */ }
)
}
// Bottom Navigation
WalletBottomNavigation(
selectedTab = selectedTab,
onTabSelected = { selectedTab = it }
)
}
// Dialogs
if (showSendDialog) {
SendDialog(
viewModel = walletViewModel,
onDismiss = { walletViewModel.hideSendDialog() }
)
}
if (showReceiveDialog) {
ReceiveDialog(
viewModel = walletViewModel,
onDismiss = { walletViewModel.hideReceiveDialog() }
)
}
}
@Composable
private fun WalletBottomNavigation(
selectedTab: Int,
onTabSelected: (Int) -> Unit
) {
NavigationBar(
containerColor = Color(0xFF1A1A1A),
tonalElevation = 8.dp
) {
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.AccountBalanceWallet,
contentDescription = "Wallet",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "Wallet",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 0,
onClick = { onTabSelected(0) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.History,
contentDescription = "History",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "History",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 1,
onClick = { onTabSelected(1) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.AccountBalance,
contentDescription = "Mints",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "Mints",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 2,
onClick = { onTabSelected(2) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
NavigationBarItem(
icon = {
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Settings",
modifier = Modifier.size(20.dp)
)
},
label = {
Text(
text = "Settings",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
selected = selectedTab == 3,
onClick = { onTabSelected(3) },
colors = NavigationBarItemDefaults.colors(
selectedIconColor = Color(0xFF00C851),
selectedTextColor = Color(0xFF00C851),
unselectedIconColor = Color.Gray,
unselectedTextColor = Color.Gray,
indicatorColor = Color(0xFF00C851).copy(alpha = 0.2f)
)
)
}
}
@@ -0,0 +1,395 @@
package com.bitchat.android.wallet.ui
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
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.graphics.Color
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 androidx.lifecycle.viewmodel.compose.viewModel
import com.bitchat.android.wallet.viewmodel.WalletViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WalletSettings(
viewModel: WalletViewModel = viewModel(),
onBackClick: () -> Unit = {}
) {
val colorScheme = MaterialTheme.colorScheme
var showClearDataDialog by remember { mutableStateOf(false) }
var showExportDialog by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxSize()
.background(colorScheme.surface)
) {
// Header
TopAppBar(
title = {
Text(
text = "Wallet Settings",
fontFamily = FontFamily.Monospace,
fontSize = 18.sp,
fontWeight = FontWeight.Bold
)
},
navigationIcon = {
IconButton(onClick = onBackClick) {
Icon(
imageVector = Icons.Filled.ArrowBack,
contentDescription = "Back"
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = colorScheme.surface,
titleContentColor = colorScheme.onSurface,
navigationIconContentColor = colorScheme.onSurface
)
)
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Wallet Info Section
item {
SettingsSection(title = "Wallet Information") {
SettingsCard {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
InfoRow("Wallet Version", "0.1.0-beta")
Spacer(modifier = Modifier.height(8.dp))
InfoRow("Protocol Version", "Cashu v1")
Spacer(modifier = Modifier.height(8.dp))
InfoRow("Active Mints", "${viewModel.mints.value?.size ?: 0}")
Spacer(modifier = Modifier.height(8.dp))
InfoRow("Total Transactions", "${viewModel.transactions.value?.size ?: 0}")
}
}
}
}
// Security Section
item {
SettingsSection(title = "Security") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsItem(
icon = Icons.Filled.FileDownload,
title = "Export Wallet Data",
description = "Export transaction history and mint info",
onClick = { showExportDialog = true }
)
SettingsItem(
icon = Icons.Filled.Security,
title = "View Seed Phrase",
description = "Display wallet recovery information",
onClick = { /* TODO: Implement seed phrase display */ }
)
SettingsItem(
icon = Icons.Filled.Warning,
title = "Clear Wallet Data",
description = "Remove all wallet data (irreversible)",
onClick = { showClearDataDialog = true },
textColor = Color(0xFFFF5722)
)
}
}
}
// Network Section
item {
SettingsSection(title = "Network") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsItem(
icon = Icons.Filled.Public,
title = "Network Statistics",
description = "View network connectivity information",
onClick = { /* TODO: Show network stats */ }
)
SettingsItem(
icon = Icons.Filled.Refresh,
title = "Sync All Mints",
description = "Refresh mint information and keysets",
onClick = { viewModel.syncAllMints() }
)
}
}
}
// Development Section
item {
SettingsSection(title = "Development") {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
SettingsItem(
icon = Icons.Filled.BugReport,
title = "Debug Mode",
description = "Enable detailed logging and debug features",
onClick = { /* TODO: Toggle debug mode */ }
)
SettingsItem(
icon = Icons.Filled.Code,
title = "Developer Tools",
description = "Advanced tools for testing and debugging",
onClick = { /* TODO: Open dev tools */ }
)
}
}
}
// About Section
item {
SettingsSection(title = "About") {
SettingsCard {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = "bitchat Cashu Wallet",
fontFamily = FontFamily.Monospace,
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = colorScheme.onSurface
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "A privacy-focused Cashu ecash wallet integrated with bitchat mesh networking.",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = colorScheme.onSurface.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Built with the Cashu Development Kit (CDK)",
fontFamily = FontFamily.Monospace,
fontSize = 10.sp,
color = colorScheme.onSurface.copy(alpha = 0.5f)
)
}
}
}
}
}
}
// Clear Data Confirmation Dialog
if (showClearDataDialog) {
AlertDialog(
onDismissRequest = { showClearDataDialog = false },
title = {
Text(
text = "Clear Wallet Data?",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "This will permanently delete all wallet data including:\n\n" +
"• Transaction history\n" +
"• Saved mints\n" +
"• Wallet balance\n" +
"• Settings\n\n" +
"This action cannot be undone!",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.clearAllWalletData()
showClearDataDialog = false
}
) {
Text(
text = "Clear Data",
fontFamily = FontFamily.Monospace,
color = Color(0xFFFF5722),
fontWeight = FontWeight.Bold
)
}
},
dismissButton = {
TextButton(onClick = { showClearDataDialog = false }) {
Text(
text = "Cancel",
fontFamily = FontFamily.Monospace
)
}
}
)
}
// Export Data Dialog
if (showExportDialog) {
AlertDialog(
onDismissRequest = { showExportDialog = false },
title = {
Text(
text = "Export Wallet Data",
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold
)
},
text = {
Text(
text = "This will export your transaction history and mint information to a JSON file. " +
"This does NOT include your private keys or wallet secrets.",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp
)
},
confirmButton = {
TextButton(
onClick = {
viewModel.exportWalletData()
showExportDialog = false
}
) {
Text(
text = "Export",
fontFamily = FontFamily.Monospace,
color = Color(0xFF4CAF50),
fontWeight = FontWeight.Bold
)
}
},
dismissButton = {
TextButton(onClick = { showExportDialog = false }) {
Text(
text = "Cancel",
fontFamily = FontFamily.Monospace
)
}
}
)
}
}
@Composable
private fun SettingsSection(
title: String,
content: @Composable () -> Unit
) {
Column {
Text(
text = title,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(bottom = 8.dp)
)
content()
}
}
@Composable
private fun SettingsCard(
modifier: Modifier = Modifier,
content: @Composable () -> Unit
) {
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
) {
content()
}
}
@Composable
private fun SettingsItem(
icon: ImageVector,
title: String,
description: String,
onClick: () -> Unit,
textColor: Color = MaterialTheme.colorScheme.onSurface
) {
SettingsCard {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = textColor,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
fontFamily = FontFamily.Monospace,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
color = textColor
)
Text(
text = description,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = textColor.copy(alpha = 0.7f)
)
}
Icon(
imageVector = Icons.Filled.ChevronRight,
contentDescription = null,
tint = textColor.copy(alpha = 0.5f),
modifier = Modifier.size(16.dp)
)
}
}
}
@Composable
private fun InfoRow(
label: String,
value: String
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = "$label:",
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f)
)
Text(
text = value,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface
)
}
}
@@ -0,0 +1,675 @@
package com.bitchat.android.wallet.viewmodel
import android.app.Application
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import com.bitchat.android.wallet.data.*
import com.bitchat.android.wallet.repository.WalletRepository
import com.bitchat.android.wallet.service.CashuService
import kotlinx.coroutines.launch
import kotlinx.coroutines.delay
import android.util.Log
import java.math.BigDecimal
import java.util.*
/**
* ViewModel for managing Cashu wallet state and operations
*/
class WalletViewModel(application: Application) : AndroidViewModel(application) {
private val repository = WalletRepository.getInstance(application)
private val cashuService = CashuService.getInstance()
companion object {
private const val TAG = "WalletViewModel"
private const val POLLING_INTERVAL = 5000L // 5 seconds
}
// Observable data
private val _balance = MutableLiveData<Long>(0L)
val balance: LiveData<Long> = _balance
private val _isLoading = MutableLiveData<Boolean>(false)
val isLoading: LiveData<Boolean> = _isLoading
private val _errorMessage = MutableLiveData<String?>(null)
val errorMessage: LiveData<String?> = _errorMessage
private val _mints = MutableLiveData<List<Mint>>(emptyList())
val mints: LiveData<List<Mint>> = _mints
private val _activeMint = MutableLiveData<String?>(null)
val activeMint: LiveData<String?> = _activeMint
private val _transactions = MutableLiveData<List<WalletTransaction>>(emptyList())
val transactions: LiveData<List<WalletTransaction>> = _transactions
private val _pendingMintQuotes = MutableLiveData<List<MintQuote>>(emptyList())
val pendingMintQuotes: LiveData<List<MintQuote>> = _pendingMintQuotes
private val _pendingMeltQuotes = MutableLiveData<List<MeltQuote>>(emptyList())
val pendingMeltQuotes: LiveData<List<MeltQuote>> = _pendingMeltQuotes
// Send/Receive dialog state
private val _showSendDialog = MutableLiveData<Boolean>(false)
val showSendDialog: LiveData<Boolean> = _showSendDialog
private val _showReceiveDialog = MutableLiveData<Boolean>(false)
val showReceiveDialog: LiveData<Boolean> = _showReceiveDialog
private val _sendType = MutableLiveData<SendType>(SendType.CASHU)
val sendType: LiveData<SendType> = _sendType
private val _receiveType = MutableLiveData<ReceiveType>(ReceiveType.CASHU)
val receiveType: LiveData<ReceiveType> = _receiveType
// Mints tab state
private val _showAddMintDialog = MutableLiveData<Boolean>(false)
val showAddMintDialog: LiveData<Boolean> = _showAddMintDialog
// Current operations state
private val _generatedToken = MutableLiveData<String?>(null)
val generatedToken: LiveData<String?> = _generatedToken
private val _decodedToken = MutableLiveData<CashuToken?>(null)
val decodedToken: LiveData<CashuToken?> = _decodedToken
private val _currentMintQuote = MutableLiveData<MintQuote?>(null)
val currentMintQuote: LiveData<MintQuote?> = _currentMintQuote
private val _currentMeltQuote = MutableLiveData<MeltQuote?>(null)
val currentMeltQuote: LiveData<MeltQuote?> = _currentMeltQuote
// State management
private var pollingJob: kotlinx.coroutines.Job? = null
enum class SendType { CASHU, LIGHTNING }
enum class ReceiveType { CASHU, LIGHTNING }
init {
loadInitialData()
startPolling()
}
/**
* Load initial wallet data
*/
private fun loadInitialData() {
viewModelScope.launch {
try {
// Load mints
repository.getMints().onSuccess { mintList ->
_mints.value = mintList
}
// Load active mint and initialize wallet
repository.getActiveMint().onSuccess { mintUrl ->
if (!mintUrl.isNullOrEmpty()) {
_activeMint.value = mintUrl
initializeWalletWithMint(mintUrl)
} else {
// Load mints first and set first as active if none selected
repository.getMints().onSuccess { mintList ->
_mints.value = mintList
if (mintList.isNotEmpty()) {
val firstMint = mintList.first().url
setActiveMint(firstMint)
}
}
}
}
// Load transactions
loadTransactions()
// Load pending quotes
loadPendingQuotes()
} catch (e: Exception) {
Log.e(TAG, "Failed to load initial data", e)
_errorMessage.value = "Failed to load wallet data: ${e.message}"
}
}
}
/**
* Initialize wallet with specific mint
*/
private suspend fun initializeWalletWithMint(mintUrl: String) {
try {
_isLoading.value = true
cashuService.initializeWallet(mintUrl).onSuccess {
refreshBalance()
Log.d(TAG, "Wallet initialized with mint: $mintUrl")
}.onFailure { error ->
Log.e(TAG, "Failed to initialize wallet with mint: $mintUrl", error)
_errorMessage.value = "Failed to connect to mint: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
/**
* Refresh wallet balance
*/
fun refreshBalance() {
viewModelScope.launch {
try {
cashuService.getBalance().onSuccess { balance ->
_balance.value = balance
}.onFailure { error ->
Log.e(TAG, "Failed to get balance", error)
}
} catch (e: Exception) {
Log.e(TAG, "Exception while refreshing balance", e)
}
}
}
/**
* Start polling for quote updates
*/
private fun startPolling() {
pollingJob = viewModelScope.launch {
while (true) {
try {
checkPendingQuotes()
delay(POLLING_INTERVAL)
} catch (e: Exception) {
Log.e(TAG, "Error during polling", e)
delay(POLLING_INTERVAL)
}
}
}
}
/**
* Check pending quotes for updates
*/
private suspend fun checkPendingQuotes() {
// Check mint quotes
repository.getMintQuotes().onSuccess { quotes ->
val unpaidQuotes = quotes.filter { !it.paid }
for (quote in unpaidQuotes) {
cashuService.checkAndMintQuote(quote.id).onSuccess { success ->
if (success) {
// Update quote status and add transaction
val updatedQuote = quote.copy(paid = true, state = MintQuoteState.PAID)
repository.saveMintQuote(updatedQuote)
val transaction = WalletTransaction(
id = UUID.randomUUID().toString(),
type = TransactionType.LIGHTNING_RECEIVE,
amount = quote.amount,
unit = quote.unit,
status = TransactionStatus.CONFIRMED,
timestamp = Date(),
description = "Lightning payment received",
quote = quote.id
)
repository.saveTransaction(transaction)
loadTransactions()
refreshBalance()
Log.d(TAG, "Mint quote ${quote.id} was paid and minted")
}
}
}
_pendingMintQuotes.value = unpaidQuotes
}
// Note: Melt quotes typically don't need polling as they're immediately processed
repository.getMeltQuotes().onSuccess { quotes ->
_pendingMeltQuotes.value = quotes.filter { it.state != MeltQuoteState.PAID }
}
}
/**
* Load transaction history
*/
private fun loadTransactions() {
viewModelScope.launch {
repository.getLastTransactions(10).onSuccess { txList ->
_transactions.value = txList
}
}
}
/**
* Load pending quotes
*/
private fun loadPendingQuotes() {
viewModelScope.launch {
repository.getMintQuotes().onSuccess { quotes ->
_pendingMintQuotes.value = quotes.filter { !it.paid }
}
repository.getMeltQuotes().onSuccess { quotes ->
_pendingMeltQuotes.value = quotes.filter { it.state != MeltQuoteState.PAID }
}
}
}
// UI Action Methods
fun showSendDialog() {
_showSendDialog.value = true
}
fun hideSendDialog() {
_showSendDialog.value = false
_generatedToken.value = null
_currentMeltQuote.value = null
_sendType.value = SendType.CASHU
}
fun showReceiveDialog() {
_showReceiveDialog.value = true
}
fun hideReceiveDialog() {
_showReceiveDialog.value = false
_decodedToken.value = null
_currentMintQuote.value = null
_receiveType.value = ReceiveType.CASHU
}
fun setSendType(type: SendType) {
_sendType.value = type
}
fun setReceiveType(type: ReceiveType) {
_receiveType.value = type
}
fun showAddMintDialog() {
_showAddMintDialog.value = true
}
fun hideAddMintDialog() {
_showAddMintDialog.value = false
}
fun clearError() {
_errorMessage.value = null
}
// Wallet Operations
/**
* Create a Cashu token
*/
fun createCashuToken(amount: Long, memo: String? = null) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.createToken(amount, memo).onSuccess { token ->
_generatedToken.value = 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)
loadTransactions()
refreshBalance()
}.onFailure { error ->
_errorMessage.value = "Failed to create token: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Decode a Cashu token to show information
*/
fun decodeCashuToken(token: String) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.decodeToken(token).onSuccess { decodedToken ->
_decodedToken.value = decodedToken
}.onFailure { error ->
_errorMessage.value = "Invalid token: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Receive a Cashu token
*/
fun receiveCashuToken(token: String) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.receiveToken(token).onSuccess { amount ->
// Add transaction
val transaction = WalletTransaction(
id = UUID.randomUUID().toString(),
type = TransactionType.CASHU_RECEIVE,
amount = BigDecimal(amount),
unit = "sat",
status = TransactionStatus.CONFIRMED,
timestamp = Date(),
description = "Cashu token received",
token = token
)
repository.saveTransaction(transaction)
loadTransactions()
refreshBalance()
hideReceiveDialog()
}.onFailure { error ->
_errorMessage.value = "Failed to receive token: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Create Lightning mint quote (for receiving)
*/
fun createMintQuote(amount: Long, description: String? = null) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.createMintQuote(amount, description).onSuccess { quote ->
_currentMintQuote.value = quote
// Save quote for tracking
repository.saveMintQuote(quote)
loadPendingQuotes()
}.onFailure { error ->
_errorMessage.value = "Failed to create invoice: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Create Lightning melt quote (for sending)
*/
fun createMeltQuote(invoice: String) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.createMeltQuote(invoice).onSuccess { quote ->
_currentMeltQuote.value = quote
// Save quote for tracking
repository.saveMeltQuote(quote)
loadPendingQuotes()
}.onFailure { error ->
_errorMessage.value = "Failed to process invoice: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Pay Lightning invoice (melt)
*/
fun payLightningInvoice(quoteId: String) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.payInvoice(quoteId).onSuccess { success ->
if (success) {
// Update quote status and add transaction
val quote = _currentMeltQuote.value
if (quote != null) {
val updatedQuote = quote.copy(paid = true, state = MeltQuoteState.PAID)
repository.saveMeltQuote(updatedQuote)
val transaction = WalletTransaction(
id = UUID.randomUUID().toString(),
type = TransactionType.LIGHTNING_SEND,
amount = quote.amount,
unit = quote.unit,
status = TransactionStatus.CONFIRMED,
timestamp = Date(),
description = "Lightning payment sent",
quote = quote.id,
fee = quote.feeReserve
)
repository.saveTransaction(transaction)
loadTransactions()
refreshBalance()
hideSendDialog()
}
} else {
_errorMessage.value = "Payment failed"
}
}.onFailure { error ->
_errorMessage.value = "Payment failed: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
// Mint Management
/**
* Add a new mint
*/
fun addMint(mintUrl: String, nickname: String) {
viewModelScope.launch {
try {
_isLoading.value = true
cashuService.getMintInfo(mintUrl).onSuccess { mintInfo ->
val mint = Mint(
url = mintUrl,
nickname = nickname.ifEmpty { mintInfo.name },
info = mintInfo,
keysets = emptyList(), // Will be populated by CDK
active = true,
dateAdded = Date()
)
repository.saveMint(mint).onSuccess {
// Reload mints
repository.getMints().onSuccess { mintList ->
_mints.value = mintList
}
// Set as active mint if it's the first one
if (_activeMint.value.isNullOrEmpty()) {
setActiveMint(mintUrl)
}
hideAddMintDialog()
}.onFailure { error ->
_errorMessage.value = "Failed to save mint: ${error.message}"
}
}.onFailure { error ->
_errorMessage.value = "Failed to connect to mint: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Set the active mint
*/
fun setActiveMint(mintUrl: String) {
viewModelScope.launch {
try {
_isLoading.value = true
repository.setActiveMint(mintUrl).onSuccess {
_activeMint.value = mintUrl
initializeWalletWithMint(mintUrl)
}.onFailure { error ->
_errorMessage.value = "Failed to set active mint: ${error.message}"
}
} finally {
_isLoading.value = false
}
}
}
/**
* Update mint nickname
*/
fun updateMintNickname(mintUrl: String, newNickname: String) {
viewModelScope.launch {
val currentMints = _mints.value ?: emptyList()
val mintToUpdate = currentMints.find { it.url == mintUrl }
if (mintToUpdate != null) {
val updatedMint = mintToUpdate.copy(nickname = newNickname)
repository.saveMint(updatedMint).onSuccess {
// Reload mints
repository.getMints().onSuccess { mintList ->
_mints.value = mintList
}
}.onFailure { error ->
_errorMessage.value = "Failed to update mint nickname: ${error.message}"
}
}
}
}
/**
* Sync all mints - refresh mint information and keysets
*/
fun syncAllMints() {
viewModelScope.launch {
try {
_isLoading.value = true
val currentMints = _mints.value ?: emptyList()
for (mint in currentMints) {
try {
cashuService.getMintInfo(mint.url).onSuccess { mintInfo ->
val updatedMint = mint.copy(
info = mintInfo,
lastSync = Date()
)
repository.saveMint(updatedMint)
}
} catch (e: Exception) {
Log.e(TAG, "Error syncing mint ${mint.url}", e)
}
}
// Reload mints list
repository.getMints().onSuccess { mintList ->
_mints.value = mintList
}
} catch (e: Exception) {
Log.e(TAG, "Error syncing mints", e)
_errorMessage.value = "Failed to sync mints: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
/**
* Clear all wallet data
*/
fun clearAllWalletData() {
viewModelScope.launch {
try {
_isLoading.value = true
pollingJob?.cancel()
// Clear all data
repository.clearAllData()
// Reset state
_balance.value = 0L
_mints.value = emptyList()
_activeMint.value = null
_transactions.value = emptyList()
_pendingMintQuotes.value = emptyList()
_pendingMeltQuotes.value = emptyList()
_currentMintQuote.value = null
_currentMeltQuote.value = null
_generatedToken.value = null
_decodedToken.value = null
// Restart polling
startPolling()
} catch (e: Exception) {
Log.e(TAG, "Error clearing wallet data", e)
_errorMessage.value = "Failed to clear data: ${e.message}"
} finally {
_isLoading.value = false
}
}
}
/**
* Export wallet data (for backup/debugging)
*/
fun exportWalletData() {
viewModelScope.launch {
try {
val exportData = mapOf(
"mints" to _mints.value,
"transactions" to _transactions.value,
"activeMint" to _activeMint.value,
"balance" to _balance.value,
"exportDate" to Date().toString(),
"version" to "1.0"
)
// TODO: Implement actual file export - for now just log
Log.d(TAG, "Export data prepared: ${exportData.keys}")
// In a real implementation, you would:
// 1. Convert to JSON
// 2. Save to external storage with user permission
// 3. Or share via intent
} catch (e: Exception) {
Log.e(TAG, "Error exporting wallet data", e)
_errorMessage.value = "Failed to export data: ${e.message}"
}
}
}
/**
* Get full transaction history (not just last 10)
*/
fun getAllTransactions(): LiveData<List<WalletTransaction>> {
val allTransactions = MutableLiveData<List<WalletTransaction>>()
viewModelScope.launch {
repository.getAllTransactions().onSuccess { txList ->
allTransactions.value = txList
}
}
return allTransactions
}
}
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,84 @@
package com.bitchat.android.parsing
import org.junit.Test
import org.junit.Assert.*
/**
* Unit tests for MessageParser and CashuTokenParser
*/
class MessageParserTest {
private val messageParser = MessageParser.instance
@Test
fun testPlainTextMessage() {
val content = "Hello world! This is a regular message."
val elements = messageParser.parseMessage(content)
assertEquals(1, elements.size)
assertTrue(elements[0] is MessageElement.Text)
assertEquals(content, (elements[0] as MessageElement.Text).content)
}
@Test
fun testMessageWithCashuToken() {
// Test with a mock Cashu token pattern
val content = "Here's a payment: cashuBeyJ0b2tlbiI6W3sibWludCI6Imh0dHA6Ly9sb2NhbGhvc3Q6MzMzOCIsInByb29mcyI6W3siYW1vdW50IjoyLCJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJzZWNyZXQiOiI0MDc5MTViYzIxMmJlNjFhNzdlM2U2ZDJhZWI0YzcyNzk4MGJkYTUxY2QwNmE2YWZjMjllMjg2MTc2OGE3ODM3IiwiQyI6IjAyYmM5MDk3OTk3ZDgxYWZiMmNjNzM0NmI1ZTQzNDVhOTM0NmJkMmE1MDZlYjc5NTg1OThhNzJmMGNmODUxNjNlYSJ9LHsiYW1vdW50Ijo4LCJpZCI6IjAwOWExZjI5MzI1M2U0MWUiLCJzZWNyZXQiOiJmZTE1MTA5MzE0ZTYxZDc3NTZiMGY4ZWUwZjIzYTYyNGFjYWEzZjRlMDQyZjYxNDMzYzcyOGM3MDU3YjkzMWJlIiwiQyI6IjAyOWU4ZTUwNTBiODkwYTdkNmMwOTY4ZGIxNmJjMWQ1ZDVmYTA0MGVhMWRlMjg0ZjZlYzY5ZDYxMjk5ZjY3MTA1OSJ9XX1dLCJ1bml0Ijoic2F0IiwibWVtbyI6IlRoYW5rIHlvdS4ifQ please receive it!"
val elements = messageParser.parseMessage(content)
// Should find: text "Here's a payment: " + cashu token + text " please receive it!"
assertTrue(elements.size >= 3)
// Check structure - should have both text and payment elements
assertTrue(elements.any { it is MessageElement.Text })
assertTrue(elements.any { it is MessageElement.CashuPayment })
}
@Test
fun testMultipleCashuTokensInMessage() {
val content = "First: cashuBdGVzdDE and second: cashuBdGVzdDI"
val elements = messageParser.parseMessage(content)
// Should find text + cashu + text + cashu elements
val cashuElements = elements.filterIsInstance<MessageElement.CashuPayment>()
assertEquals(2, cashuElements.size)
}
@Test
fun testInvalidCashuToken() {
// This should fall back to creating a token with placeholder data
val content = "Token: cashuBinvaliddata123"
val elements = messageParser.parseMessage(content)
val cashuElements = elements.filterIsInstance<MessageElement.CashuPayment>()
assertEquals(1, cashuElements.size)
// Should have fallback values
val token = cashuElements[0].token
assertEquals(100L, token.amount)
assertEquals("sat", token.unit)
}
@Test
fun testCashuTokenParser() {
val parser = CashuTokenParser()
// Test with invalid token
val invalidResult = parser.parseToken("invalid")
assertNull(invalidResult)
// Test with token that doesn't start with cashuB
val invalidPrefixResult = parser.parseToken("cashuAinvaliddata")
assertNull(invalidPrefixResult)
// Test with empty token
val emptyResult = parser.parseToken("cashuB")
assertNull(emptyResult)
// Test with mock token (will use fallback)
val mockResult = parser.parseToken("cashuBdGVzdA")
assertNotNull(mockResult)
assertEquals(100L, mockResult!!.amount)
assertEquals("sat", mockResult.unit)
}
}