feat(android): 实现交易记录功能

- TssRepository 添加交易记录管理方法 (saveTransactionRecord, updateTransactionStatus, confirmTransaction, getTransactionRecords)
- 添加历史交易同步功能 (syncERC20TransactionHistory, syncNativeTransactionHistory, syncAllTransactionHistory)
- MainViewModel 添加交易记录状态和后台确认轮询
- 新建 TransactionHistoryScreen 交易记录列表界面
- WalletsScreen 添加"记录"按钮入口
- 转账成功后自动保存记录并后台确认状态
- 首次导入钱包时自动同步链上历史交易

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-26 06:55:25 -08:00
parent 7b3d28c957
commit 3727b0e817
5 changed files with 1014 additions and 2 deletions

View File

@ -110,6 +110,10 @@ fun TssPartyApp(
val exportResult by viewModel.exportResult.collectAsState()
val importResult by viewModel.importResult.collectAsState()
// Transaction history state
val transactionRecords by viewModel.transactionRecords.collectAsState()
val isSyncingHistory by viewModel.isSyncingHistory.collectAsState()
// Current transfer wallet
var transferWalletId by remember { mutableStateOf<Long?>(null) }
@ -296,6 +300,9 @@ fun TssPartyApp(
transferWalletId = shareId
navController.navigate("transfer/$shareId")
},
onHistory = { shareId, address ->
navController.navigate("history/$shareId/$address")
},
onExportBackup = { shareId, _ ->
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] ========== onExportBackup called ==========")
android.util.Log.d("MainActivity", "[EXPORT-TRIGGER] shareId: $shareId")
@ -378,6 +385,27 @@ fun TssPartyApp(
}
}
// Transaction History Screen
composable("history/{shareId}/{address}") { backStackEntry ->
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull() ?: 0L
val address = backStackEntry.arguments?.getString("address") ?: ""
// Load records when entering screen
LaunchedEffect(shareId) {
viewModel.loadTransactionRecords(shareId)
}
TransactionHistoryScreen(
shareId = shareId,
walletAddress = address,
transactions = transactionRecords,
networkType = settings.networkType,
isSyncing = isSyncingHistory,
onBack = { navController.popBackStack() },
onRefresh = { viewModel.syncTransactionHistory(shareId, address) }
)
}
// Tab 2: Create Wallet (创建钱包)
composable(BottomNavItem.Create.route) {
CreateWalletScreen(

View File

@ -2844,7 +2844,6 @@ class TssRepository @Inject constructor(
}
return data
}
}
/**
* Result of creating a keygen session
@ -2976,6 +2975,478 @@ data class ParticipantStatusInfo(
val status: String
)
// ========== Transaction Record Methods ==========
/**
* 保存交易记录
* 转账发起后立即调用初始状态为 PENDING
*/
suspend fun saveTransactionRecord(
shareId: Long,
fromAddress: String,
toAddress: String,
amount: String,
tokenType: TokenType,
txHash: String,
gasPrice: String,
direction: String = "SENT"
): Long {
return withContext(Dispatchers.IO) {
android.util.Log.d("TssRepository", "[TX-RECORD] Saving transaction record: txHash=${txHash.take(20)}...")
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = fromAddress,
toAddress = toAddress,
amount = amount,
tokenType = tokenType.name,
txHash = txHash,
gasPrice = gasPrice,
status = "PENDING",
direction = direction,
createdAt = System.currentTimeMillis()
)
val id = transactionRecordDao.insertRecord(entity)
android.util.Log.d("TssRepository", "[TX-RECORD] Saved with id: $id")
id
}
}
/**
* 更新交易状态
* 确认交易后调用更新状态为 CONFIRMED FAILED
*/
suspend fun updateTransactionStatus(
txHash: String,
status: String,
gasUsed: String,
txFee: String,
blockNumber: Long?,
confirmedAt: Long?
) {
withContext(Dispatchers.IO) {
android.util.Log.d("TssRepository", "[TX-RECORD] Updating status for txHash=${txHash.take(20)}... to $status")
val record = transactionRecordDao.getRecordByTxHash(txHash)
if (record != null) {
transactionRecordDao.updateStatus(
id = record.id,
status = status,
confirmedAt = confirmedAt,
blockNumber = blockNumber,
gasUsed = gasUsed,
txFee = txFee
)
android.util.Log.d("TssRepository", "[TX-RECORD] Status updated successfully")
} else {
android.util.Log.w("TssRepository", "[TX-RECORD] Record not found for txHash=${txHash.take(20)}...")
}
}
}
/**
* 获取钱包的交易记录列表
*/
fun getTransactionRecords(shareId: Long): Flow<List<TransactionRecordEntity>> {
return transactionRecordDao.getRecordsForShare(shareId)
}
/**
* 获取所有待确认的交易
*/
suspend fun getPendingTransactions(): List<TransactionRecordEntity> {
return withContext(Dispatchers.IO) {
transactionRecordDao.getPendingRecords()
}
}
/**
* 确认交易状态
* 查询链上交易收据并更新本地状态
*/
suspend fun confirmTransaction(txHash: String, rpcUrl: String): Result<Boolean> {
return withContext(Dispatchers.IO) {
try {
android.util.Log.d("TssRepository", "[TX-CONFIRM] Checking receipt for txHash=${txHash.take(20)}...")
val receiptResult = TransactionUtils.getTransactionReceipt(txHash, rpcUrl)
receiptResult.fold(
onSuccess = { receipt ->
if (receipt != null) {
val status = if (receipt.status) "CONFIRMED" else "FAILED"
val gasUsed = receipt.gasUsed.toString()
val blockNumber = try {
java.math.BigInteger(receipt.blockNumber.removePrefix("0x"), 16).toLong()
} catch (e: Exception) { null }
// 计算交易费用 (gasUsed * gasPrice)
val record = transactionRecordDao.getRecordByTxHash(txHash)
val txFee = if (record != null && gasUsed.isNotEmpty()) {
val gasUsedBig = java.math.BigInteger(gasUsed)
val gasPriceBig = java.math.BigInteger(record.gasPrice)
val feeWei = gasUsedBig.multiply(gasPriceBig)
// 转换为 KAVA (18 decimals)
val feeKava = feeWei.toBigDecimal().divide(java.math.BigDecimal("1000000000000000000"))
feeKava.stripTrailingZeros().toPlainString()
} else ""
updateTransactionStatus(
txHash = txHash,
status = status,
gasUsed = gasUsed,
txFee = txFee,
blockNumber = blockNumber,
confirmedAt = System.currentTimeMillis()
)
android.util.Log.d("TssRepository", "[TX-CONFIRM] Transaction $status: blockNumber=$blockNumber")
Result.success(true)
} else {
android.util.Log.d("TssRepository", "[TX-CONFIRM] Receipt not yet available")
Result.success(false) // 交易还未确认
}
},
onFailure = { e ->
android.util.Log.e("TssRepository", "[TX-CONFIRM] Error: ${e.message}")
Result.failure(e)
}
)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "[TX-CONFIRM] Exception: ${e.message}", e)
Result.failure(e)
}
}
}
// ========== Transaction History Sync Methods ==========
/**
* 同步 ERC-20 代币的历史交易
* 使用 eth_getLogs 查询 Transfer 事件
*/
suspend fun syncERC20TransactionHistory(
shareId: Long,
address: String,
contractAddress: String,
tokenType: TokenType,
rpcUrl: String
): Result<Int> {
return withContext(Dispatchers.IO) {
try {
android.util.Log.d("TssRepository", "[SYNC-ERC20] Syncing ${tokenType.name} history for $address")
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
val jsonMediaType = "application/json; charset=utf-8".toMediaType()
// Transfer event signature: keccak256("Transfer(address,address,uint256)")
val transferTopic = "0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
// 地址需要补齐到 32 字节(左侧补零)
val paddedAddress = "0x" + address.removePrefix("0x").lowercase().padStart(64, '0')
var totalSynced = 0
// 查询发送的交易 (from = address)
val sentRequest = """
{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [{
"address": "$contractAddress",
"topics": ["$transferTopic", "$paddedAddress", null],
"fromBlock": "earliest",
"toBlock": "latest"
}],
"id": 1
}
""".trimIndent()
val sentResponse = client.newCall(
okhttp3.Request.Builder()
.url(rpcUrl)
.post(sentRequest.toRequestBody(jsonMediaType))
.build()
).execute()
val sentBody = sentResponse.body?.string()
if (sentBody != null) {
totalSynced += parseAndSaveTransferLogs(sentBody, shareId, address, tokenType, "SENT")
}
// 查询接收的交易 (to = address)
val receivedRequest = """
{
"jsonrpc": "2.0",
"method": "eth_getLogs",
"params": [{
"address": "$contractAddress",
"topics": ["$transferTopic", null, "$paddedAddress"],
"fromBlock": "earliest",
"toBlock": "latest"
}],
"id": 2
}
""".trimIndent()
val receivedResponse = client.newCall(
okhttp3.Request.Builder()
.url(rpcUrl)
.post(receivedRequest.toRequestBody(jsonMediaType))
.build()
).execute()
val receivedBody = receivedResponse.body?.string()
if (receivedBody != null) {
totalSynced += parseAndSaveTransferLogs(receivedBody, shareId, address, tokenType, "RECEIVED")
}
android.util.Log.d("TssRepository", "[SYNC-ERC20] Synced $totalSynced ${tokenType.name} transactions")
Result.success(totalSynced)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "[SYNC-ERC20] Error: ${e.message}", e)
Result.failure(e)
}
}
}
/**
* 解析 Transfer 日志并保存到数据库
*/
private suspend fun parseAndSaveTransferLogs(
responseBody: String,
shareId: Long,
address: String,
tokenType: TokenType,
direction: String
): Int {
var count = 0
try {
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
val result = json.getAsJsonArray("result") ?: return 0
for (log in result) {
try {
val logObj = log.asJsonObject
val txHash = logObj.get("transactionHash")?.asString ?: continue
val topics = logObj.getAsJsonArray("topics")
val data = logObj.get("data")?.asString ?: "0x0"
val blockNumber = logObj.get("blockNumber")?.asString
// 检查是否已存在
val existing = transactionRecordDao.getRecordByTxHash(txHash)
if (existing != null) continue
// 解析 from 和 to 地址 (topics[1] 和 topics[2])
val fromAddress = if (topics.size() > 1) {
"0x" + topics[1].asString.removePrefix("0x").takeLast(40)
} else continue
val toAddress = if (topics.size() > 2) {
"0x" + topics[2].asString.removePrefix("0x").takeLast(40)
} else continue
// 解析金额 (data 字段)
val valueHex = data.removePrefix("0x")
val valueBig = if (valueHex.isNotEmpty()) {
java.math.BigInteger(valueHex, 16)
} else java.math.BigInteger.ZERO
// 转换为人类可读格式 (根据代币精度)
val decimals = TokenConfig.getDecimals(tokenType)
val divisor = java.math.BigDecimal.TEN.pow(decimals)
val humanReadable = valueBig.toBigDecimal().divide(divisor).stripTrailingZeros().toPlainString()
// 解析区块号
val blockNum = blockNumber?.let {
java.math.BigInteger(it.removePrefix("0x"), 16).toLong()
}
// 保存记录
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = fromAddress,
toAddress = toAddress,
amount = humanReadable,
tokenType = tokenType.name,
txHash = txHash,
gasPrice = "0", // 历史交易无法获取 gasPrice
status = "CONFIRMED",
direction = direction,
blockNumber = blockNum,
confirmedAt = System.currentTimeMillis()
)
transactionRecordDao.insertRecord(entity)
count++
} catch (e: Exception) {
android.util.Log.w("TssRepository", "[SYNC-ERC20] Error parsing log: ${e.message}")
}
}
} catch (e: Exception) {
android.util.Log.e("TssRepository", "[SYNC-ERC20] Error parsing response: ${e.message}")
}
return count
}
/**
* 同步原生 KAVA 历史交易
* 使用 KavaScan API
*/
suspend fun syncNativeTransactionHistory(
shareId: Long,
address: String,
networkType: NetworkType = NetworkType.MAINNET
): Result<Int> {
return withContext(Dispatchers.IO) {
try {
android.util.Log.d("TssRepository", "[SYNC-KAVA] Syncing native KAVA history for $address")
val baseUrl = if (networkType == NetworkType.TESTNET) {
"https://testnet.kavascan.com"
} else {
"https://kavascan.com"
}
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(30, java.util.concurrent.TimeUnit.SECONDS)
.build()
// KavaScan API 格式(类似 Etherscan
val apiUrl = "$baseUrl/api?module=account&action=txlist&address=$address&sort=desc"
val request = okhttp3.Request.Builder()
.url(apiUrl)
.get()
.build()
val response = client.newCall(request).execute()
val responseBody = response.body?.string()
if (responseBody == null) {
android.util.Log.w("TssRepository", "[SYNC-KAVA] Empty response from KavaScan")
return@withContext Result.success(0)
}
var count = 0
try {
val json = com.google.gson.JsonParser.parseString(responseBody).asJsonObject
val status = json.get("status")?.asString
val result = json.getAsJsonArray("result")
if (status == "1" && result != null) {
for (tx in result) {
try {
val txObj = tx.asJsonObject
val txHash = txObj.get("hash")?.asString ?: continue
val fromAddr = txObj.get("from")?.asString ?: continue
val toAddr = txObj.get("to")?.asString ?: continue
val valueStr = txObj.get("value")?.asString ?: "0"
val gasPrice = txObj.get("gasPrice")?.asString ?: "0"
val gasUsed = txObj.get("gasUsed")?.asString ?: "0"
val blockNum = txObj.get("blockNumber")?.asString?.toLongOrNull()
val timestamp = txObj.get("timeStamp")?.asString?.toLongOrNull()?.times(1000)
val isError = txObj.get("isError")?.asString == "1"
// 检查是否已存在
val existing = transactionRecordDao.getRecordByTxHash(txHash)
if (existing != null) continue
// 转换金额 (Wei -> KAVA, 18 decimals)
val valueBig = java.math.BigInteger(valueStr)
val valueKava = valueBig.toBigDecimal()
.divide(java.math.BigDecimal("1000000000000000000"))
.stripTrailingZeros()
.toPlainString()
// 计算交易费
val txFee = if (gasUsed.isNotEmpty() && gasPrice.isNotEmpty()) {
val fee = java.math.BigInteger(gasUsed).multiply(java.math.BigInteger(gasPrice))
fee.toBigDecimal()
.divide(java.math.BigDecimal("1000000000000000000"))
.stripTrailingZeros()
.toPlainString()
} else ""
// 判断方向
val direction = if (fromAddr.equals(address, ignoreCase = true)) "SENT" else "RECEIVED"
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = fromAddr,
toAddress = toAddr,
amount = valueKava,
tokenType = TokenType.KAVA.name,
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = txFee,
status = if (isError) "FAILED" else "CONFIRMED",
direction = direction,
blockNumber = blockNum,
createdAt = timestamp ?: System.currentTimeMillis(),
confirmedAt = timestamp ?: System.currentTimeMillis()
)
transactionRecordDao.insertRecord(entity)
count++
} catch (e: Exception) {
android.util.Log.w("TssRepository", "[SYNC-KAVA] Error parsing tx: ${e.message}")
}
}
}
} catch (e: Exception) {
android.util.Log.e("TssRepository", "[SYNC-KAVA] Error parsing response: ${e.message}")
}
android.util.Log.d("TssRepository", "[SYNC-KAVA] Synced $count native KAVA transactions")
Result.success(count)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "[SYNC-KAVA] Error: ${e.message}", e)
Result.failure(e)
}
}
}
/**
* 同步所有类型的历史交易
* 首次导入钱包时调用
*/
suspend fun syncAllTransactionHistory(
shareId: Long,
address: String,
rpcUrl: String,
networkType: NetworkType = NetworkType.MAINNET
): Result<Int> {
return withContext(Dispatchers.IO) {
android.util.Log.d("TssRepository", "[SYNC-ALL] Starting full transaction history sync for $address")
var totalSynced = 0
// 同步 ERC-20 代币
val tokenTypes = listOf(
TokenType.GREEN_POINTS to GreenPointsToken.CONTRACT_ADDRESS,
TokenType.ENERGY_POINTS to EnergyPointsToken.CONTRACT_ADDRESS,
TokenType.FUTURE_POINTS to FuturePointsToken.CONTRACT_ADDRESS
)
for ((tokenType, contractAddress) in tokenTypes) {
syncERC20TransactionHistory(shareId, address, contractAddress, tokenType, rpcUrl)
.onSuccess { count -> totalSynced += count }
.onFailure { e ->
android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync ${tokenType.name}: ${e.message}")
}
}
// 同步原生 KAVA
syncNativeTransactionHistory(shareId, address, networkType)
.onSuccess { count -> totalSynced += count }
.onFailure { e ->
android.util.Log.w("TssRepository", "[SYNC-ALL] Failed to sync native KAVA: ${e.message}")
}
android.util.Log.d("TssRepository", "[SYNC-ALL] Completed: synced $totalSynced total transactions")
Result.success(totalSynced)
}
}
}
private fun ShareRecordEntity.toShareRecord() = ShareRecord(
id = id,
sessionId = sessionId,

View File

@ -0,0 +1,386 @@
package com.durian.tssparty.presentation.screens
import android.content.Intent
import android.net.Uri
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.durian.tssparty.data.local.TransactionRecordEntity
import com.durian.tssparty.domain.model.EnergyPointsToken
import com.durian.tssparty.domain.model.FuturePointsToken
import com.durian.tssparty.domain.model.GreenPointsToken
import com.durian.tssparty.domain.model.NetworkType
import java.text.SimpleDateFormat
import java.util.*
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionHistoryScreen(
shareId: Long,
walletAddress: String,
transactions: List<TransactionRecordEntity>,
networkType: NetworkType,
isSyncing: Boolean,
onBack: () -> Unit,
onRefresh: () -> Unit
) {
val context = LocalContext.current
Scaffold(
topBar = {
TopAppBar(
title = { Text("交易记录") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
},
actions = {
if (isSyncing) {
CircularProgressIndicator(
modifier = Modifier
.size(24.dp)
.padding(end = 8.dp),
strokeWidth = 2.dp
)
} else {
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "刷新")
}
}
}
)
}
) { paddingValues ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp)
) {
// Wallet address header
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = walletAddress,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Spacer(modifier = Modifier.height(8.dp))
// Transaction count
Text(
text = "${transactions.size} 条记录",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(12.dp))
if (transactions.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无交易记录",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = if (isSyncing) "正在同步中..." else "发起转账后将在此显示",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
// Transaction list
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
contentPadding = PaddingValues(bottom = 16.dp)
) {
items(
items = transactions.sortedByDescending { it.createdAt },
key = { it.id }
) { tx ->
TransactionItemCard(
transaction = tx,
walletAddress = walletAddress,
networkType = networkType,
onClick = {
// Open transaction in block explorer
val explorerUrl = getExplorerUrl(networkType, tx.txHash)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl))
context.startActivity(intent)
}
)
}
}
}
}
}
}
@Composable
private fun TransactionItemCard(
transaction: TransactionRecordEntity,
walletAddress: String,
networkType: NetworkType,
onClick: () -> Unit
) {
val isSent = transaction.direction == "SENT" ||
transaction.fromAddress.equals(walletAddress, ignoreCase = true)
val statusColor = when (transaction.status) {
"CONFIRMED" -> Color(0xFF4CAF50) // Green
"FAILED" -> MaterialTheme.colorScheme.error
else -> Color(0xFFFF9800) // Orange for PENDING
}
val tokenColor = when (transaction.tokenType) {
"GREEN_POINTS" -> Color(0xFF4CAF50)
"ENERGY_POINTS" -> Color(0xFF2196F3)
"FUTURE_POINTS" -> Color(0xFF9C27B0)
else -> MaterialTheme.colorScheme.primary // KAVA
}
val tokenName = when (transaction.tokenType) {
"GREEN_POINTS" -> GreenPointsToken.NAME
"ENERGY_POINTS" -> EnergyPointsToken.NAME
"FUTURE_POINTS" -> FuturePointsToken.NAME
else -> "KAVA"
}
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() }
) {
Column(
modifier = Modifier.padding(12.dp)
) {
// Row 1: Direction icon + Amount + Status
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
// Direction icon
Box(
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (isSent)
MaterialTheme.colorScheme.errorContainer
else
Color(0xFFE8F5E9)
),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward,
contentDescription = if (isSent) "发送" else "接收",
tint = if (isSent)
MaterialTheme.colorScheme.error
else
Color(0xFF4CAF50),
modifier = Modifier.size(20.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
// Amount and token
Column {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "${if (isSent) "-" else "+"}${transaction.amount}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isSent)
MaterialTheme.colorScheme.error
else
Color(0xFF4CAF50)
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = tokenName,
style = MaterialTheme.typography.bodySmall,
color = tokenColor
)
}
Text(
text = if (isSent) "发送" else "接收",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
// Status badge
Surface(
color = statusColor.copy(alpha = 0.15f),
shape = RoundedCornerShape(4.dp)
) {
Text(
text = when (transaction.status) {
"CONFIRMED" -> "已确认"
"FAILED" -> "失败"
else -> "待确认"
},
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = statusColor
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Divider(color = MaterialTheme.colorScheme.outlineVariant)
Spacer(modifier = Modifier.height(8.dp))
// Row 2: Address (to/from)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Column {
Text(
text = if (isSent) "发送至" else "来自",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = if (isSent) shortenAddress(transaction.toAddress) else shortenAddress(transaction.fromAddress),
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace
)
}
Column(horizontalAlignment = Alignment.End) {
Text(
text = "时间",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
Text(
text = formatTimestamp(transaction.createdAt),
style = MaterialTheme.typography.bodySmall
)
}
}
// Row 3: Tx Hash (abbreviated)
Spacer(modifier = Modifier.height(4.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "交易哈希: ${shortenTxHash(transaction.txHash)}",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.width(4.dp))
Icon(
Icons.Default.OpenInNew,
contentDescription = "查看详情",
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.outline
)
}
// Row 4: Fee (if confirmed)
if (transaction.status == "CONFIRMED" && transaction.txFee.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "手续费: ${transaction.txFee} KAVA",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
private fun shortenAddress(address: String): String {
return if (address.length > 16) {
"${address.take(10)}...${address.takeLast(6)}"
} else {
address
}
}
private fun shortenTxHash(txHash: String): String {
return if (txHash.length > 20) {
"${txHash.take(10)}...${txHash.takeLast(8)}"
} else {
txHash
}
}
private fun formatTimestamp(timestamp: Long): String {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return sdf.format(Date(timestamp))
}
private fun getExplorerUrl(networkType: NetworkType, txHash: String): String {
return when (networkType) {
NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash"
NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash"
}
}

View File

@ -57,6 +57,7 @@ fun WalletsScreen(
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
onTransfer: ((shareId: Long) -> Unit)? = null,
onHistory: ((shareId: Long, address: String) -> Unit)? = null,
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
onImportBackup: (() -> Unit)? = null,
onCreateWallet: (() -> Unit)? = null
@ -157,6 +158,9 @@ fun WalletsScreen(
onTransfer = {
onTransfer?.invoke(share.id)
},
onHistory = {
onHistory?.invoke(share.id, share.address)
},
onDelete = { onDeleteShare(share.id) }
)
}
@ -225,6 +229,7 @@ private fun WalletItemCard(
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onHistory: () -> Unit,
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@ -435,6 +440,16 @@ private fun WalletItemCard(
Text("转账")
}
TextButton(onClick = onHistory) {
Icon(
Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(18.dp)
)
Spacer(modifier = Modifier.width(4.dp))
Text("记录")
}
TextButton(
onClick = { showDeleteDialog = true },
colors = ButtonDefaults.textButtonColors(

View File

@ -2,8 +2,8 @@ package com.durian.tssparty.presentation.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.durian.tssparty.data.repository.JoinKeygenViaGrpcResult
import com.durian.tssparty.data.repository.TssRepository
import com.durian.tssparty.data.repository.TssRepository.JoinKeygenViaGrpcResult
import com.durian.tssparty.domain.model.*
import com.durian.tssparty.util.AddressUtils
import com.durian.tssparty.util.TransactionUtils
@ -917,6 +917,66 @@ class MainViewModel @Inject constructor(
}
}
// ========== Transaction Records ==========
private val _transactionRecords = MutableStateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>>(emptyList())
val transactionRecords: StateFlow<List<com.durian.tssparty.data.local.TransactionRecordEntity>> = _transactionRecords.asStateFlow()
private val _isSyncingHistory = MutableStateFlow(false)
val isSyncingHistory: StateFlow<Boolean> = _isSyncingHistory.asStateFlow()
/**
* 加载钱包的交易记录
*/
fun loadTransactionRecords(shareId: Long) {
viewModelScope.launch {
repository.getTransactionRecords(shareId).collect { records ->
_transactionRecords.value = records
}
}
}
/**
* 同步钱包的所有历史交易
* 首次导入钱包时调用
*/
fun syncTransactionHistory(shareId: Long, address: String) {
viewModelScope.launch {
_isSyncingHistory.value = true
android.util.Log.d("MainViewModel", "[SYNC] Starting transaction history sync for $address")
val rpcUrl = _settings.value.kavaRpcUrl
val networkType = _settings.value.networkType
val result = repository.syncAllTransactionHistory(shareId, address, rpcUrl, networkType)
result.fold(
onSuccess = { count ->
android.util.Log.d("MainViewModel", "[SYNC] Synced $count transactions")
},
onFailure = { e ->
android.util.Log.e("MainViewModel", "[SYNC] Error syncing: ${e.message}")
}
)
_isSyncingHistory.value = false
}
}
/**
* 确认所有待处理的交易
* 应用启动时调用
*/
fun confirmPendingTransactions() {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
val pendingRecords = repository.getPendingTransactions()
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Found ${pendingRecords.size} pending transactions")
for (record in pendingRecords) {
repository.confirmTransaction(record.txHash, rpcUrl)
}
}
}
// ========== Share Export/Import ==========
private val _exportResult = MutableStateFlow<ExportImportResult?>(null)
@ -991,6 +1051,10 @@ class MainViewModel @Inject constructor(
// Fetch balance for the imported wallet
android.util.Log.d("MainViewModel", "[IMPORT] Fetching balance...")
fetchBalanceForShare(share)
// Sync transaction history from blockchain (first-time import)
android.util.Log.d("MainViewModel", "[IMPORT] Starting transaction history sync...")
syncTransactionHistory(share.id, share.address)
android.util.Log.d("MainViewModel", "[IMPORT] Import complete!")
},
onFailure = { e ->
@ -1390,6 +1454,23 @@ class MainViewModel @Inject constructor(
onSuccess = { hash ->
android.util.Log.d("MainViewModel", "[BROADCAST] SUCCESS! txHash=$hash")
_txHash.value = hash
// 保存交易记录到本地数据库
val state = _transferState.value
android.util.Log.d("MainViewModel", "[BROADCAST] Saving transaction record: shareId=${state.shareId}, tokenType=${state.tokenType}")
repository.saveTransactionRecord(
shareId = state.shareId,
fromAddress = tx.from,
toAddress = tx.to,
amount = state.amount,
tokenType = state.tokenType,
txHash = hash,
gasPrice = tx.gasPrice.toString()
)
// 启动后台确认交易状态
confirmTransactionInBackground(hash, rpcUrl)
_uiState.update { it.copy(isLoading = false, successMessage = "交易已广播!") }
},
onFailure = { e ->
@ -1400,6 +1481,37 @@ class MainViewModel @Inject constructor(
}
}
/**
* 后台确认交易状态
* 3 秒轮询一次最多尝试 60 3 分钟
*/
private fun confirmTransactionInBackground(txHash: String, rpcUrl: String) {
viewModelScope.launch {
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Starting background confirmation for $txHash")
var attempts = 0
val maxAttempts = 60
while (attempts < maxAttempts) {
kotlinx.coroutines.delay(3000) // 等待 3 秒
attempts++
val result = repository.confirmTransaction(txHash, rpcUrl)
result.fold(
onSuccess = { confirmed ->
if (confirmed) {
android.util.Log.d("MainViewModel", "[TX-CONFIRM] Transaction confirmed after $attempts attempts")
return@launch
}
},
onFailure = { e ->
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Error checking confirmation: ${e.message}")
}
)
}
android.util.Log.w("MainViewModel", "[TX-CONFIRM] Max attempts reached, transaction may still be pending")
}
}
/**
* Reset transfer state
*/