mirror of
https://github.com/permissionlesstech/bitchat-android.git
synced 2026-07-25 09:05:21 +00:00
compiles
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
Executable
BIN
Binary file not shown.
Executable
BIN
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user