fix(kline): align sparse K-lines to the right side like OKX

When K-line data is sparse (fewer bars than screen width), the K-lines
now display right-aligned instead of left-aligned. This matches the
standard behavior of professional trading apps like OKX.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-01-19 20:00:20 -08:00
parent 5dfef3172e
commit f93c7987ea
10 changed files with 1724 additions and 38 deletions

View File

@ -790,7 +790,11 @@
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/\")",
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")",
"Bash(where:*)",
"Bash(npx md-to-pdf:*)"
"Bash(npx md-to-pdf:*)",
"Bash(node -e \"\nconst { ethers } = require\\(''ethers''\\);\nconst provider = new ethers.JsonRpcProvider\\(''https://evm.kava.io''\\);\nconst wallet = new ethers.Wallet\\(''0x886ea4cffe76c386fecf3ff321ac9ae913737c46c17bc6ce2413752144668a2a'', provider\\);\n\\(async \\(\\) => {\n console.log\\(''Deployer address:'', wallet.address\\);\n const balance = await provider.getBalance\\(wallet.address\\);\n console.log\\(''KAVA Balance:'', ethers.formatEther\\(balance\\), ''KAVA''\\);\n}\\)\\(\\);\n\")",
"Bash(./gradlew compileDebugKotlin:*)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" add frontend/mining-app/lib/presentation/widgets/kline_chart/kline_chart_widget.dart)",
"Bash(git -C \"c:\\\\Users\\\\dong\\\\Desktop\\\\rwadurian\" commit -m \"fix\\(kline\\): 稀疏数据时让最新K线居中而非整组居中\" -m \"修正居中逻辑当K线数量少时让最新的那根K线最右边显示在屏幕中央而不是让整个K线组居中。\" -m \"Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>\")"
],
"deny": [],
"ask": []

View File

@ -256,6 +256,10 @@ fun TssPartyApp(
transferWalletId = shareId
navController.navigate("transfer/$shareId")
},
onViewTransactionHistory = { shareId ->
// 导航到转账历史页面
navController.navigate("transaction_history/$shareId")
},
onExportBackup = { shareId, _ ->
// Get address for filename
val share = shares.find { it.id == shareId }
@ -325,6 +329,56 @@ fun TssPartyApp(
}
}
// Transaction History Screen (转账记录)
composable("transaction_history/{shareId}") { backStackEntry ->
val shareId = backStackEntry.arguments?.getString("shareId")?.toLongOrNull()
val wallet = shareId?.let { viewModel.getWalletById(it) }
if (wallet != null) {
// 获取转账记录
val transactions by viewModel.getTransactionRecordsFlow(shareId).collectAsState()
val ledgerSummary by viewModel.ledgerSummary.collectAsState()
var selectedTokenFilter by remember { mutableStateOf<TokenType?>(null) }
// 首次加载时获取分类账汇总
LaunchedEffect(shareId) {
viewModel.loadLedgerSummary(shareId, TokenType.KAVA)
viewModel.checkPendingTransactions()
}
TransactionHistoryScreen(
wallet = wallet,
transactions = if (selectedTokenFilter != null) {
transactions.filter { it.tokenType == selectedTokenFilter }
} else {
transactions
},
ledgerSummary = ledgerSummary,
networkType = settings.networkType,
onRefresh = {
viewModel.checkPendingTransactions()
if (selectedTokenFilter != null) {
viewModel.loadLedgerSummary(shareId, selectedTokenFilter!!)
} else {
viewModel.loadLedgerSummary(shareId, TokenType.KAVA)
}
},
onFilterByToken = { tokenType ->
selectedTokenFilter = tokenType
if (tokenType != null) {
viewModel.loadLedgerSummary(shareId, tokenType)
} else {
viewModel.loadLedgerSummary(shareId, TokenType.KAVA)
}
},
onBack = {
viewModel.clearLedgerSummary()
navController.popBackStack()
}
)
}
}
// Tab 2: Create Wallet (创建钱包)
composable(BottomNavItem.Create.route) {
CreateWalletScreen(

View File

@ -93,15 +93,210 @@ interface AppSettingDao {
suspend fun setValue(setting: AppSettingEntity)
}
/**
* 转账记录数据库实体
* Entity for storing transaction history records
*/
@Entity(
tableName = "transaction_records",
foreignKeys = [
ForeignKey(
entity = ShareRecordEntity::class,
parentColumns = ["id"],
childColumns = ["share_id"],
onDelete = ForeignKey.CASCADE // 删除钱包时自动删除关联的转账记录
)
],
indices = [
Index(value = ["share_id"]),
Index(value = ["tx_hash"], unique = true),
Index(value = ["from_address"]),
Index(value = ["to_address"]),
Index(value = ["created_at"])
]
)
data class TransactionRecordEntity(
@PrimaryKey(autoGenerate = true)
val id: Long = 0,
@ColumnInfo(name = "share_id")
val shareId: Long, // 关联的钱包ID
@ColumnInfo(name = "from_address")
val fromAddress: String, // 发送方地址
@ColumnInfo(name = "to_address")
val toAddress: String, // 接收方地址
@ColumnInfo(name = "amount")
val amount: String, // 转账金额(人类可读格式)
@ColumnInfo(name = "token_type")
val tokenType: String, // 代币类型KAVA 或 GREEN_POINTS
@ColumnInfo(name = "tx_hash")
val txHash: String, // 交易哈希
@ColumnInfo(name = "gas_price")
val gasPrice: String, // Gas 价格Wei
@ColumnInfo(name = "gas_used")
val gasUsed: String = "", // 实际消耗的 Gas
@ColumnInfo(name = "tx_fee")
val txFee: String = "", // 交易手续费
@ColumnInfo(name = "status")
val status: String, // 交易状态PENDING, CONFIRMED, FAILED
@ColumnInfo(name = "direction")
val direction: String, // 交易方向SENT, RECEIVED
@ColumnInfo(name = "note")
val note: String = "", // 备注
@ColumnInfo(name = "created_at")
val createdAt: Long = System.currentTimeMillis(),
@ColumnInfo(name = "confirmed_at")
val confirmedAt: Long? = null, // 确认时间
@ColumnInfo(name = "block_number")
val blockNumber: Long? = null // 区块高度
)
/**
* 转账记录 DAO
* Data Access Object for transaction records
*/
@Dao
interface TransactionRecordDao {
/**
* 插入新的转账记录
*/
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertRecord(record: TransactionRecordEntity): Long
/**
* 根据ID获取转账记录
*/
@Query("SELECT * FROM transaction_records WHERE id = :id")
suspend fun getRecordById(id: Long): TransactionRecordEntity?
/**
* 根据交易哈希获取转账记录
*/
@Query("SELECT * FROM transaction_records WHERE tx_hash = :txHash")
suspend fun getRecordByTxHash(txHash: String): TransactionRecordEntity?
/**
* 获取指定钱包的所有转账记录按时间倒序
*/
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC")
fun getRecordsForShare(shareId: Long): Flow<List<TransactionRecordEntity>>
/**
* 获取指定钱包的转账记录带分页
*/
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId ORDER BY created_at DESC LIMIT :limit OFFSET :offset")
suspend fun getRecordsForSharePaged(shareId: Long, limit: Int, offset: Int): List<TransactionRecordEntity>
/**
* 获取指定地址的所有转账记录作为发送方或接收方
*/
@Query("SELECT * FROM transaction_records WHERE from_address = :address OR to_address = :address ORDER BY created_at DESC")
fun getRecordsByAddress(address: String): Flow<List<TransactionRecordEntity>>
/**
* 获取指定钱包和代币类型的转账记录
*/
@Query("SELECT * FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType ORDER BY created_at DESC")
fun getRecordsForShareByToken(shareId: Long, tokenType: String): Flow<List<TransactionRecordEntity>>
/**
* 获取待确认的转账记录
*/
@Query("SELECT * FROM transaction_records WHERE status = 'PENDING' ORDER BY created_at ASC")
suspend fun getPendingRecords(): List<TransactionRecordEntity>
/**
* 更新转账记录状态
*/
@Query("UPDATE transaction_records SET status = :status, confirmed_at = :confirmedAt, block_number = :blockNumber, gas_used = :gasUsed, tx_fee = :txFee WHERE id = :id")
suspend fun updateStatus(id: Long, status: String, confirmedAt: Long?, blockNumber: Long?, gasUsed: String, txFee: String)
/**
* 获取指定钱包的转账记录统计
*/
@Query("""
SELECT
COUNT(*) as total_count,
SUM(CASE WHEN direction = 'SENT' THEN 1 ELSE 0 END) as sent_count,
SUM(CASE WHEN direction = 'RECEIVED' THEN 1 ELSE 0 END) as received_count
FROM transaction_records
WHERE share_id = :shareId AND token_type = :tokenType
""")
suspend fun getTransactionStats(shareId: Long, tokenType: String): TransactionStats
/**
* 获取指定钱包的总转出金额按代币类型
*/
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'SENT' AND status = 'CONFIRMED'")
suspend fun getTotalSentAmount(shareId: Long, tokenType: String): Double
/**
* 获取指定钱包的总转入金额按代币类型
*/
@Query("SELECT COALESCE(SUM(CAST(amount AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND token_type = :tokenType AND direction = 'RECEIVED' AND status = 'CONFIRMED'")
suspend fun getTotalReceivedAmount(shareId: Long, tokenType: String): Double
/**
* 获取指定钱包的总手续费
*/
@Query("SELECT COALESCE(SUM(CAST(tx_fee AS REAL)), 0) FROM transaction_records WHERE share_id = :shareId AND direction = 'SENT' AND status = 'CONFIRMED'")
suspend fun getTotalTxFee(shareId: Long): Double
/**
* 删除指定的转账记录
*/
@Query("DELETE FROM transaction_records WHERE id = :id")
suspend fun deleteRecordById(id: Long)
/**
* 删除指定钱包的所有转账记录
*/
@Query("DELETE FROM transaction_records WHERE share_id = :shareId")
suspend fun deleteRecordsForShare(shareId: Long)
/**
* 获取转账记录总数
*/
@Query("SELECT COUNT(*) FROM transaction_records WHERE share_id = :shareId")
suspend fun getRecordCount(shareId: Long): Int
}
/**
* 转账统计数据类
*/
data class TransactionStats(
@ColumnInfo(name = "total_count")
val totalCount: Int,
@ColumnInfo(name = "sent_count")
val sentCount: Int,
@ColumnInfo(name = "received_count")
val receivedCount: Int
)
/**
* Room database
*/
@Database(
entities = [ShareRecordEntity::class, AppSettingEntity::class],
version = 3, // Version 3: added party_id column to share_records
entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class],
version = 4, // Version 4: added transaction_records table for transfer history
exportSchema = false
)
abstract class TssDatabase : RoomDatabase() {
abstract fun shareRecordDao(): ShareRecordDao
abstract fun appSettingDao(): AppSettingDao
abstract fun transactionRecordDao(): TransactionRecordDao
}

View File

@ -5,6 +5,8 @@ import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.AppSettingEntity
import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.ShareRecordEntity
import com.durian.tssparty.data.local.TransactionRecordDao
import com.durian.tssparty.data.local.TransactionRecordEntity
import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient
import com.durian.tssparty.data.remote.GrpcConnectionEvent
@ -31,7 +33,8 @@ class TssRepository @Inject constructor(
private val grpcClient: GrpcClient,
private val tssNativeBridge: TssNativeBridge,
private val shareRecordDao: ShareRecordDao,
private val appSettingDao: AppSettingDao
private val appSettingDao: AppSettingDao,
private val transactionRecordDao: TransactionRecordDao
) {
private val _currentSession = MutableStateFlow<TssSession?>(null)
val currentSession: StateFlow<TssSession?> = _currentSession.asStateFlow()
@ -1883,20 +1886,32 @@ class TssRepository @Inject constructor(
}
/**
* Export a share as backup JSON string
* Export a share as backup JSON string (includes transaction records)
* 导出钱包备份包含转账记录
*
* @param shareId The ID of the share to export
* @param includeTransactions Whether to include transaction records (default: true)
* @return Result containing the backup JSON string
*/
suspend fun exportShareBackup(shareId: Long): Result<String> {
suspend fun exportShareBackup(shareId: Long, includeTransactions: Boolean = true): Result<String> {
return withContext(Dispatchers.IO) {
try {
val share = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("钱包不存在"))
val backup = ShareBackup.fromShareRecord(share.toShareRecord())
val backup = if (includeTransactions) {
// 获取该钱包的所有转账记录
val transactionEntities = transactionRecordDao.getRecordsForSharePaged(shareId, Int.MAX_VALUE, 0)
val transactionRecords = transactionEntities.map { it.toTransactionRecord() }
ShareBackup.fromShareRecordWithTransactions(share.toShareRecord(), transactionRecords)
} else {
ShareBackup.fromShareRecord(share.toShareRecord())
}
val json = com.google.gson.Gson().toJson(backup)
android.util.Log.d("TssRepository", "Exported share backup for address: ${share.address}")
val txCount = backup.transactionRecords?.size ?: 0
android.util.Log.d("TssRepository", "Exported share backup for address: ${share.address}, with $txCount transaction records")
Result.success(json)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to export share backup", e)
@ -1906,7 +1921,9 @@ class TssRepository @Inject constructor(
}
/**
* Import a share from backup JSON string
* Import a share from backup JSON string (includes transaction records restoration)
* 从备份 JSON 字符串导入钱包包含转账记录恢复
*
* @param backupJson The backup JSON string to import
* @return Result containing the imported ShareRecord
*/
@ -1940,7 +1957,44 @@ class TssRepository @Inject constructor(
val newId = shareRecordDao.insertShare(entity)
val savedShare = shareRecord.copy(id = newId)
android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}")
// 恢复转账记录(如果备份中包含)
var restoredTxCount = 0
if (backup.hasTransactionRecords()) {
val transactionRecords = backup.getTransactionRecords(newId)
for (txRecord in transactionRecords) {
try {
// 检查是否已存在相同的交易哈希(防止重复导入)
val existingTx = transactionRecordDao.getRecordByTxHash(txRecord.txHash)
if (existingTx == null) {
val txEntity = TransactionRecordEntity(
shareId = newId,
fromAddress = txRecord.fromAddress,
toAddress = txRecord.toAddress,
amount = txRecord.amount,
tokenType = txRecord.tokenType.name,
txHash = txRecord.txHash,
gasPrice = txRecord.gasPrice,
gasUsed = txRecord.gasUsed,
txFee = txRecord.txFee,
status = txRecord.status.name,
direction = txRecord.direction.name,
note = txRecord.note,
createdAt = txRecord.createdAt,
confirmedAt = txRecord.confirmedAt,
blockNumber = txRecord.blockNumber
)
transactionRecordDao.insertRecord(txEntity)
restoredTxCount++
}
} catch (e: Exception) {
android.util.Log.w("TssRepository", "Failed to restore transaction ${txRecord.txHash}: ${e.message}")
// 继续恢复其他记录,不中断整个导入过程
}
}
android.util.Log.d("TssRepository", "Restored $restoredTxCount transaction records")
}
android.util.Log.d("TssRepository", "Imported share backup for address: ${backup.address}, partyId: ${backup.partyId}, transactions: $restoredTxCount")
Result.success(savedShare)
} catch (e: com.google.gson.JsonSyntaxException) {
android.util.Log.e("TssRepository", "Invalid JSON format in backup", e)
@ -2510,6 +2564,310 @@ class TssRepository @Inject constructor(
return TransactionUtils.getTransactionReceipt(txHash, rpcUrl)
}
/**
* 广播交易并保存转账记录
* Broadcast transaction and save transaction record
*
* @param preparedTx 准备好的交易
* @param signature 签名
* @param rpcUrl RPC URL
* @param shareId 钱包ID
* @param amount 转账金额人类可读格式
* @param tokenType 代币类型
* @param note 备注可选
* @return 交易哈希
*/
suspend fun broadcastTransactionAndSaveRecord(
preparedTx: TransactionUtils.PreparedTransaction,
signature: String,
rpcUrl: String,
shareId: Long,
amount: String,
tokenType: TokenType,
note: String = ""
): Result<String> {
return withContext(Dispatchers.IO) {
try {
// 先广播交易
val broadcastResult = broadcastTransaction(preparedTx, signature, rpcUrl)
if (broadcastResult.isFailure) {
return@withContext Result.failure(broadcastResult.exceptionOrNull()!!)
}
val txHash = broadcastResult.getOrThrow()
// 保存转账记录
val saveResult = saveTransactionRecord(
shareId = shareId,
fromAddress = preparedTx.from,
toAddress = preparedTx.to,
amount = amount,
tokenType = tokenType,
txHash = txHash,
gasPrice = preparedTx.gasPrice.toString(),
direction = TransactionDirection.SENT,
note = note
)
if (saveResult.isFailure) {
android.util.Log.w("TssRepository", "Transaction broadcast succeeded but failed to save record: ${saveResult.exceptionOrNull()?.message}")
// 不影响返回结果,交易已成功
} else {
android.util.Log.d("TssRepository", "Transaction record saved: recordId=${saveResult.getOrNull()}, txHash=$txHash")
}
Result.success(txHash)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to broadcast transaction and save record", e)
Result.failure(e)
}
}
}
// ========== 转账记录管理方法 ==========
/**
* 保存转账记录
* Save a transaction record to the database
*
* @param shareId 钱包ID
* @param fromAddress 发送方地址
* @param toAddress 接收方地址
* @param amount 转账金额人类可读格式
* @param tokenType 代币类型
* @param txHash 交易哈希
* @param gasPrice Gas价格
* @param direction 交易方向SENT/RECEIVED
* @param note 备注可选
*/
suspend fun saveTransactionRecord(
shareId: Long,
fromAddress: String,
toAddress: String,
amount: String,
tokenType: TokenType,
txHash: String,
gasPrice: String,
direction: TransactionDirection,
note: String = ""
): Result<Long> {
return withContext(Dispatchers.IO) {
try {
val entity = TransactionRecordEntity(
shareId = shareId,
fromAddress = fromAddress,
toAddress = toAddress,
amount = amount,
tokenType = tokenType.name,
txHash = txHash,
gasPrice = gasPrice,
status = TransactionStatus.PENDING.name,
direction = direction.name,
note = note
)
val id = transactionRecordDao.insertRecord(entity)
android.util.Log.d("TssRepository", "Saved transaction record: id=$id, txHash=$txHash")
Result.success(id)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to save transaction record", e)
Result.failure(e)
}
}
}
/**
* 更新转账记录状态交易确认后调用
* Update transaction status after confirmation
*/
suspend fun updateTransactionStatus(
txHash: String,
status: TransactionStatus,
blockNumber: Long? = null,
gasUsed: String = "",
txFee: String = ""
): Result<Unit> {
return withContext(Dispatchers.IO) {
try {
val record = transactionRecordDao.getRecordByTxHash(txHash)
if (record != null) {
val confirmedAt = if (status == TransactionStatus.CONFIRMED) System.currentTimeMillis() else null
transactionRecordDao.updateStatus(
id = record.id,
status = status.name,
confirmedAt = confirmedAt,
blockNumber = blockNumber,
gasUsed = gasUsed,
txFee = txFee
)
android.util.Log.d("TssRepository", "Updated transaction status: txHash=$txHash, status=$status")
Result.success(Unit)
} else {
Result.failure(Exception("Transaction record not found for txHash: $txHash"))
}
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to update transaction status", e)
Result.failure(e)
}
}
}
/**
* 获取指定钱包的转账记录列表实时更新
* Get transaction records for a wallet as Flow
*/
fun getTransactionRecordsFlow(shareId: Long): Flow<List<TransactionRecord>> {
return transactionRecordDao.getRecordsForShare(shareId).map { entities ->
entities.map { it.toTransactionRecord() }
}
}
/**
* 获取指定钱包的转账记录列表分页
* Get transaction records with pagination
*/
suspend fun getTransactionRecordsPaged(shareId: Long, limit: Int, offset: Int): Result<List<TransactionRecord>> {
return withContext(Dispatchers.IO) {
try {
val records = transactionRecordDao.getRecordsForSharePaged(shareId, limit, offset)
Result.success(records.map { it.toTransactionRecord() })
} catch (e: Exception) {
Result.failure(e)
}
}
}
/**
* 获取指定钱包和代币类型的转账记录
* Get transaction records filtered by token type
*/
fun getTransactionRecordsByTokenFlow(shareId: Long, tokenType: TokenType): Flow<List<TransactionRecord>> {
return transactionRecordDao.getRecordsForShareByToken(shareId, tokenType.name).map { entities ->
entities.map { it.toTransactionRecord() }
}
}
/**
* 获取转账记录分类账汇总
* Get transaction ledger summary for a wallet
*/
suspend fun getTransactionLedgerSummary(shareId: Long, tokenType: TokenType): Result<TransactionLedgerSummary> {
return withContext(Dispatchers.IO) {
try {
val share = shareRecordDao.getShareById(shareId)
?: return@withContext Result.failure(Exception("Wallet not found"))
val stats = transactionRecordDao.getTransactionStats(shareId, tokenType.name)
val totalSent = transactionRecordDao.getTotalSentAmount(shareId, tokenType.name)
val totalReceived = transactionRecordDao.getTotalReceivedAmount(shareId, tokenType.name)
val totalTxFee = transactionRecordDao.getTotalTxFee(shareId)
val summary = TransactionLedgerSummary(
address = share.address,
tokenType = tokenType,
totalSent = formatAmount(totalSent, tokenType),
totalReceived = formatAmount(totalReceived, tokenType),
totalTxFee = formatKavaAmount(totalTxFee),
transactionCount = stats.totalCount,
sentCount = stats.sentCount,
receivedCount = stats.receivedCount
)
Result.success(summary)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to get ledger summary", e)
Result.failure(e)
}
}
}
/**
* 检查并更新待确认的转账记录
* Check and update pending transactions
*/
suspend fun checkPendingTransactions(rpcUrl: String): Result<Int> {
return withContext(Dispatchers.IO) {
try {
val pendingRecords = transactionRecordDao.getPendingRecords()
var confirmedCount = 0
for (record in pendingRecords) {
try {
val receiptResult = TransactionUtils.getTransactionReceipt(record.txHash, rpcUrl)
if (receiptResult.isSuccess) {
val receipt = receiptResult.getOrNull()
if (receipt != null) {
// receipt.status 是 Boolean: true = 成功, false = 失败
val status = if (receipt.status) TransactionStatus.CONFIRMED else TransactionStatus.FAILED
// receipt.gasUsed 是 BigInteger
val gasUsedBi = receipt.gasUsed
// 计算手续费gasUsed * gasPrice
val gasPriceWei = java.math.BigInteger(record.gasPrice)
val txFeeWei = gasPriceWei.multiply(gasUsedBi)
val txFeeKava = java.math.BigDecimal(txFeeWei)
.divide(java.math.BigDecimal("1000000000000000000"), 9, java.math.RoundingMode.DOWN)
.stripTrailingZeros()
.toPlainString()
// blockNumber 是 String (hex),需要解析
val blockNumber = receipt.blockNumber.removePrefix("0x").toLongOrNull(16)
transactionRecordDao.updateStatus(
id = record.id,
status = status.name,
confirmedAt = System.currentTimeMillis(),
blockNumber = blockNumber,
gasUsed = gasUsedBi.toString(),
txFee = txFeeKava
)
confirmedCount++
android.util.Log.d("TssRepository", "Transaction ${record.txHash} confirmed with status: $status")
}
}
} catch (e: Exception) {
android.util.Log.w("TssRepository", "Failed to check transaction ${record.txHash}", e)
}
}
Result.success(confirmedCount)
} catch (e: Exception) {
android.util.Log.e("TssRepository", "Failed to check pending transactions", e)
Result.failure(e)
}
}
}
/**
* 获取转账记录总数
* Get total count of transaction records for a wallet
*/
suspend fun getTransactionRecordCount(shareId: Long): Int {
return transactionRecordDao.getRecordCount(shareId)
}
/**
* 格式化金额显示
*/
private fun formatAmount(amount: Double, tokenType: TokenType): String {
return when (tokenType) {
TokenType.KAVA -> formatKavaAmount(amount)
TokenType.GREEN_POINTS -> formatGreenPointsAmount(amount)
}
}
private fun formatKavaAmount(amount: Double): String {
return java.math.BigDecimal(amount)
.setScale(6, java.math.RoundingMode.DOWN)
.stripTrailingZeros()
.toPlainString()
}
private fun formatGreenPointsAmount(amount: Double): String {
return java.math.BigDecimal(amount)
.setScale(2, java.math.RoundingMode.DOWN)
.stripTrailingZeros()
.toPlainString()
}
// ========== Connection Test Methods ==========
/**
@ -2794,3 +3152,25 @@ private fun ShareRecordEntity.toShareRecord() = ShareRecord(
address = address,
createdAt = createdAt
)
/**
* 转账记录实体转换为领域模型
*/
private fun TransactionRecordEntity.toTransactionRecord() = TransactionRecord(
id = id,
shareId = shareId,
fromAddress = fromAddress,
toAddress = toAddress,
amount = amount,
tokenType = TokenType.valueOf(tokenType),
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = txFee,
status = TransactionStatus.valueOf(status),
direction = TransactionDirection.valueOf(direction),
note = note,
createdAt = createdAt,
confirmedAt = confirmedAt,
blockNumber = blockNumber
)

View File

@ -6,6 +6,7 @@ import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.durian.tssparty.data.local.AppSettingDao
import com.durian.tssparty.data.local.ShareRecordDao
import com.durian.tssparty.data.local.TransactionRecordDao
import com.durian.tssparty.data.local.TssDatabase
import com.durian.tssparty.data.local.TssNativeBridge
import com.durian.tssparty.data.remote.GrpcClient
@ -45,6 +46,42 @@ object AppModule {
}
}
// Migration from version 3 to 4: add transaction_records table for transfer history
// 添加转账记录表,用于存储交易历史和分类账
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(database: SupportSQLiteDatabase) {
// 创建转账记录表
database.execSQL("""
CREATE TABLE IF NOT EXISTS `transaction_records` (
`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
`share_id` INTEGER NOT NULL,
`from_address` TEXT NOT NULL,
`to_address` TEXT NOT NULL,
`amount` TEXT NOT NULL,
`token_type` TEXT NOT NULL,
`tx_hash` TEXT NOT NULL,
`gas_price` TEXT NOT NULL,
`gas_used` TEXT NOT NULL DEFAULT '',
`tx_fee` TEXT NOT NULL DEFAULT '',
`status` TEXT NOT NULL,
`direction` TEXT NOT NULL,
`note` TEXT NOT NULL DEFAULT '',
`created_at` INTEGER NOT NULL,
`confirmed_at` INTEGER,
`block_number` INTEGER,
FOREIGN KEY(`share_id`) REFERENCES `share_records`(`id`) ON DELETE CASCADE
)
""".trimIndent())
// 创建索引以优化查询性能
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_share_id` ON `transaction_records` (`share_id`)")
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS `index_transaction_records_tx_hash` ON `transaction_records` (`tx_hash`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_from_address` ON `transaction_records` (`from_address`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_to_address` ON `transaction_records` (`to_address`)")
database.execSQL("CREATE INDEX IF NOT EXISTS `index_transaction_records_created_at` ON `transaction_records` (`created_at`)")
}
}
@Provides
@Singleton
fun provideGson(): Gson {
@ -59,7 +96,7 @@ object AppModule {
TssDatabase::class.java,
"tss_party.db"
)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
.build()
}
@ -75,6 +112,12 @@ object AppModule {
return database.appSettingDao()
}
@Provides
@Singleton
fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao {
return database.transactionRecordDao()
}
@Provides
@Singleton
fun provideGrpcClient(): GrpcClient {
@ -93,8 +136,9 @@ object AppModule {
grpcClient: GrpcClient,
tssNativeBridge: TssNativeBridge,
shareRecordDao: ShareRecordDao,
appSettingDao: AppSettingDao
appSettingDao: AppSettingDao,
transactionRecordDao: TransactionRecordDao
): TssRepository {
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao)
return TssRepository(grpcClient, tssNativeBridge, shareRecordDao, appSettingDao, transactionRecordDao)
}
}

View File

@ -163,10 +163,15 @@ data class WalletBalance(
/**
* Share backup data for export/import
* Contains all necessary information to restore a wallet share
*
* Version history:
* - v1: Initial version
* - v2: Added partyId field for proper backup/restore
* - v3: Added transactionRecords for transaction history backup
*/
data class ShareBackup(
@SerializedName("version")
val version: Int = 2, // Version 2: added partyId field for proper backup/restore
val version: Int = 3, // Version 3: added transactionRecords for transaction history backup
@SerializedName("sessionId")
val sessionId: String,
@ -196,14 +201,18 @@ data class ShareBackup(
val createdAt: Long,
@SerializedName("exportedAt")
val exportedAt: Long = System.currentTimeMillis()
val exportedAt: Long = System.currentTimeMillis(),
@SerializedName("transactionRecords")
val transactionRecords: List<TransactionRecordBackup>? = null // 转账记录可选v3新增
) {
companion object {
const val FILE_EXTENSION = "tss-backup"
const val MIME_TYPE = "application/octet-stream"
const val CURRENT_VERSION = 3
/**
* Create backup from ShareRecord
* Create backup from ShareRecord (without transaction records)
*/
fun fromShareRecord(share: ShareRecord): ShareBackup {
return ShareBackup(
@ -218,6 +227,28 @@ data class ShareBackup(
createdAt = share.createdAt
)
}
/**
* Create backup from ShareRecord with transaction records
* 创建包含转账记录的完整备份
*/
fun fromShareRecordWithTransactions(
share: ShareRecord,
transactions: List<TransactionRecord>
): ShareBackup {
return ShareBackup(
sessionId = share.sessionId,
publicKey = share.publicKey,
encryptedShare = share.encryptedShare,
thresholdT = share.thresholdT,
thresholdN = share.thresholdN,
partyIndex = share.partyIndex,
partyId = share.partyId,
address = share.address,
createdAt = share.createdAt,
transactionRecords = transactions.map { TransactionRecordBackup.fromTransactionRecord(it) }
)
}
}
/**
@ -237,4 +268,173 @@ data class ShareBackup(
createdAt = createdAt
)
}
/**
* Get transaction records converted to domain model
* 获取转换后的转账记录列表需要提供 shareId
*/
fun getTransactionRecords(shareId: Long): List<TransactionRecord> {
return transactionRecords?.map { it.toTransactionRecord(shareId) } ?: emptyList()
}
/**
* Check if this backup has transaction records
* 检查是否包含转账记录
*/
fun hasTransactionRecords(): Boolean {
return !transactionRecords.isNullOrEmpty()
}
}
/**
* 转账记录状态枚举
* Transaction status for tracking transfer lifecycle
*/
enum class TransactionStatus {
PENDING, // 交易已广播,等待确认
CONFIRMED, // 交易已确认
FAILED // 交易失败
}
/**
* 转账方向枚举
* Direction of the transaction relative to the wallet
*/
enum class TransactionDirection {
SENT, // 转出(发送)
RECEIVED // 转入(接收)
}
/**
* 转账记录数据模型
* Stores transaction history for a wallet
*/
data class TransactionRecord(
val id: Long = 0,
val shareId: Long, // 关联的钱包ID
val fromAddress: String, // 发送方地址
val toAddress: String, // 接收方地址
val amount: String, // 转账金额(人类可读格式,如 "1.5"
val tokenType: TokenType, // 代币类型KAVA 或 GREEN_POINTS
val txHash: String, // 交易哈希
val gasPrice: String, // Gas 价格Wei
val gasUsed: String = "", // 实际消耗的 Gas确认后填入
val txFee: String = "", // 交易手续费(确认后计算)
val status: TransactionStatus, // 交易状态
val direction: TransactionDirection, // 交易方向(转入/转出)
val note: String = "", // 备注(可选)
val createdAt: Long = System.currentTimeMillis(), // 创建时间
val confirmedAt: Long? = null, // 确认时间(可选)
val blockNumber: Long? = null // 区块高度(确认后填入)
)
/**
* 转账记录分类账汇总
* Summary statistics for transaction ledger
*/
data class TransactionLedgerSummary(
val address: String,
val tokenType: TokenType,
val totalSent: String, // 总转出金额
val totalReceived: String, // 总转入金额
val totalTxFee: String, // 总手续费
val transactionCount: Int, // 交易笔数
val sentCount: Int, // 转出笔数
val receivedCount: Int // 转入笔数
)
/**
* 转账记录备份数据
* Transaction record backup data for export/import
*/
data class TransactionRecordBackup(
@SerializedName("fromAddress")
val fromAddress: String,
@SerializedName("toAddress")
val toAddress: String,
@SerializedName("amount")
val amount: String,
@SerializedName("tokenType")
val tokenType: String, // KAVA 或 GREEN_POINTS
@SerializedName("txHash")
val txHash: String,
@SerializedName("gasPrice")
val gasPrice: String,
@SerializedName("gasUsed")
val gasUsed: String,
@SerializedName("txFee")
val txFee: String,
@SerializedName("status")
val status: String, // PENDING, CONFIRMED, FAILED
@SerializedName("direction")
val direction: String, // SENT, RECEIVED
@SerializedName("note")
val note: String,
@SerializedName("createdAt")
val createdAt: Long,
@SerializedName("confirmedAt")
val confirmedAt: Long?,
@SerializedName("blockNumber")
val blockNumber: Long?
) {
companion object {
/**
* TransactionRecord 创建备份
*/
fun fromTransactionRecord(record: TransactionRecord): TransactionRecordBackup {
return TransactionRecordBackup(
fromAddress = record.fromAddress,
toAddress = record.toAddress,
amount = record.amount,
tokenType = record.tokenType.name,
txHash = record.txHash,
gasPrice = record.gasPrice,
gasUsed = record.gasUsed,
txFee = record.txFee,
status = record.status.name,
direction = record.direction.name,
note = record.note,
createdAt = record.createdAt,
confirmedAt = record.confirmedAt,
blockNumber = record.blockNumber
)
}
}
/**
* 转换为 TransactionRecord需要提供 shareId
*/
fun toTransactionRecord(shareId: Long): TransactionRecord {
return TransactionRecord(
id = 0, // 自动生成
shareId = shareId,
fromAddress = fromAddress,
toAddress = toAddress,
amount = amount,
tokenType = TokenType.valueOf(tokenType),
txHash = txHash,
gasPrice = gasPrice,
gasUsed = gasUsed,
txFee = txFee,
status = TransactionStatus.valueOf(status),
direction = TransactionDirection.valueOf(direction),
note = note,
createdAt = createdAt,
confirmedAt = confirmedAt,
blockNumber = blockNumber
)
}
}

View File

@ -0,0 +1,728 @@
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.CircleShape
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.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
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.durian.tssparty.domain.model.*
import java.text.SimpleDateFormat
import java.util.*
/**
* 转账历史界面
* Transaction History Screen - displays transaction records and ledger summary
*
* Features:
* - 交易记录列表按时间倒序
* - 分类账汇总统计
* - 按代币类型筛选
* - 交易详情查看
* - 区块链浏览器链接
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TransactionHistoryScreen(
wallet: ShareRecord,
transactions: List<TransactionRecord>,
ledgerSummary: TransactionLedgerSummary?,
networkType: NetworkType = NetworkType.MAINNET,
onRefresh: () -> Unit,
onFilterByToken: (TokenType?) -> Unit,
onBack: () -> Unit
) {
var selectedTokenFilter by remember { mutableStateOf<TokenType?>(null) }
var showLedgerSummary by remember { mutableStateOf(true) }
var selectedTransaction by remember { mutableStateOf<TransactionRecord?>(null) }
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
// 顶部导航栏
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "返回")
}
Text(
text = "转账记录",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
IconButton(onClick = onRefresh) {
Icon(Icons.Default.Refresh, contentDescription = "刷新")
}
}
Spacer(modifier = Modifier.height(8.dp))
// 钱包地址显示
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.AccountBalanceWallet,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = formatAddress(wallet.address),
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
}
}
Spacer(modifier = Modifier.height(12.dp))
// 代币筛选标签
TokenFilterChips(
selectedToken = selectedTokenFilter,
onSelectToken = { token ->
selectedTokenFilter = token
onFilterByToken(token)
}
)
Spacer(modifier = Modifier.height(12.dp))
// 分类账汇总卡片(可折叠)
LedgerSummaryCard(
summary = ledgerSummary,
expanded = showLedgerSummary,
onToggle = { showLedgerSummary = !showLedgerSummary }
)
Spacer(modifier = Modifier.height(12.dp))
// 交易记录列表
if (transactions.isEmpty()) {
// 空状态
Box(
modifier = Modifier
.fillMaxWidth()
.weight(1f),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
imageVector = Icons.Default.Receipt,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(16.dp))
Text(
text = "暂无转账记录",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.outline
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "完成转账后记录会显示在这里",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
} else {
LazyColumn(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(transactions) { transaction ->
TransactionRecordItem(
transaction = transaction,
walletAddress = wallet.address,
networkType = networkType,
onClick = { selectedTransaction = transaction }
)
}
}
}
}
// 交易详情弹窗
selectedTransaction?.let { tx ->
TransactionDetailDialog(
transaction = tx,
networkType = networkType,
onDismiss = { selectedTransaction = null }
)
}
}
/**
* 代币筛选标签
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TokenFilterChips(
selectedToken: TokenType?,
onSelectToken: (TokenType?) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
FilterChip(
selected = selectedToken == null,
onClick = { onSelectToken(null) },
label = { Text("全部") },
leadingIcon = if (selectedToken == null) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
FilterChip(
selected = selectedToken == TokenType.KAVA,
onClick = { onSelectToken(TokenType.KAVA) },
label = { Text("KAVA") },
leadingIcon = if (selectedToken == TokenType.KAVA) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
FilterChip(
selected = selectedToken == TokenType.GREEN_POINTS,
onClick = { onSelectToken(TokenType.GREEN_POINTS) },
label = { Text("绿积分") },
leadingIcon = if (selectedToken == TokenType.GREEN_POINTS) {
{ Icon(Icons.Default.Check, contentDescription = null, modifier = Modifier.size(18.dp)) }
} else null
)
}
}
/**
* 分类账汇总卡片
*/
@Composable
private fun LedgerSummaryCard(
summary: TransactionLedgerSummary?,
expanded: Boolean,
onToggle: () -> Unit
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f)
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { onToggle() }
.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Default.Analytics,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = "分类账汇总",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
}
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = if (expanded) "收起" else "展开"
)
}
if (expanded && summary != null) {
Spacer(modifier = Modifier.height(12.dp))
Divider()
Spacer(modifier = Modifier.height(12.dp))
// 汇总数据
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
// 总转出
SummaryItem(
label = "总转出",
value = summary.totalSent,
unit = getTokenSymbol(summary.tokenType),
color = MaterialTheme.colorScheme.error
)
// 总转入
SummaryItem(
label = "总转入",
value = summary.totalReceived,
unit = getTokenSymbol(summary.tokenType),
color = Color(0xFF4CAF50)
)
// 总手续费
SummaryItem(
label = "总手续费",
value = summary.totalTxFee,
unit = "KAVA",
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.height(8.dp))
// 交易笔数
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
) {
Text(
text = "${summary.transactionCount} 笔交易(转出 ${summary.sentCount} / 转入 ${summary.receivedCount}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
} else if (expanded && summary == null) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "加载中...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
}
/**
* 汇总项
*/
@Composable
private fun SummaryItem(
label: String,
value: String,
unit: String,
color: Color
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = value,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = color
)
Text(
text = unit,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* 交易记录列表项
*/
@Composable
private fun TransactionRecordItem(
transaction: TransactionRecord,
walletAddress: String,
networkType: NetworkType,
onClick: () -> Unit
) {
val isSent = transaction.direction == TransactionDirection.SENT
val context = LocalContext.current
Card(
modifier = Modifier
.fillMaxWidth()
.clickable { onClick() },
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface
),
elevation = CardDefaults.cardElevation(defaultElevation = 1.dp)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// 方向图标
Box(
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.background(
if (isSent) MaterialTheme.colorScheme.errorContainer
else Color(0xFF4CAF50).copy(alpha = 0.2f)
),
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(24.dp)
)
}
Spacer(modifier = Modifier.width(12.dp))
// 交易信息
Column(modifier = Modifier.weight(1f)) {
// 方向和地址
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isSent) "转出" else "转入",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold
)
// 状态标签
StatusChip(status = transaction.status)
}
Spacer(modifier = Modifier.height(4.dp))
// 对方地址
Text(
text = if (isSent) "${formatAddress(transaction.toAddress)}"
else "来自 ${formatAddress(transaction.fromAddress)}",
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(4.dp))
// 时间
Text(
text = formatDateTime(transaction.createdAt),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.outline
)
}
Spacer(modifier = Modifier.width(12.dp))
// 金额
Column(horizontalAlignment = Alignment.End) {
Text(
text = "${if (isSent) "-" else "+"}${transaction.amount}",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = if (isSent) MaterialTheme.colorScheme.error else Color(0xFF4CAF50)
)
Text(
text = getTokenSymbol(transaction.tokenType),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* 状态标签
*/
@Composable
private fun StatusChip(status: TransactionStatus) {
val (text, color) = when (status) {
TransactionStatus.PENDING -> "待确认" to Color(0xFFFFA000)
TransactionStatus.CONFIRMED -> "已确认" to Color(0xFF4CAF50)
TransactionStatus.FAILED -> "失败" to MaterialTheme.colorScheme.error
}
Surface(
shape = RoundedCornerShape(4.dp),
color = color.copy(alpha = 0.15f)
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = color,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
/**
* 交易详情弹窗
*/
@Composable
private fun TransactionDetailDialog(
transaction: TransactionRecord,
networkType: NetworkType,
onDismiss: () -> Unit
) {
val context = LocalContext.current
val clipboardManager = LocalClipboardManager.current
val isSent = transaction.direction == TransactionDirection.SENT
AlertDialog(
onDismissRequest = onDismiss,
title = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (isSent) Icons.Default.ArrowUpward else Icons.Default.ArrowDownward,
contentDescription = null,
tint = if (isSent) MaterialTheme.colorScheme.error else Color(0xFF4CAF50)
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = if (isSent) "转出详情" else "转入详情")
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// 金额
DetailRow(
label = "金额",
value = "${transaction.amount} ${getTokenSymbol(transaction.tokenType)}"
)
// 状态
DetailRow(
label = "状态",
value = when (transaction.status) {
TransactionStatus.PENDING -> "待确认"
TransactionStatus.CONFIRMED -> "已确认"
TransactionStatus.FAILED -> "失败"
}
)
// 发送方
DetailRow(
label = "发送方",
value = transaction.fromAddress,
isAddress = true,
onCopy = { clipboardManager.setText(AnnotatedString(transaction.fromAddress)) }
)
// 接收方
DetailRow(
label = "接收方",
value = transaction.toAddress,
isAddress = true,
onCopy = { clipboardManager.setText(AnnotatedString(transaction.toAddress)) }
)
// 交易哈希
DetailRow(
label = "交易哈希",
value = transaction.txHash,
isAddress = true,
onCopy = { clipboardManager.setText(AnnotatedString(transaction.txHash)) }
)
// 手续费(如果有)
if (transaction.txFee.isNotEmpty() && transaction.txFee != "0") {
DetailRow(
label = "手续费",
value = "${transaction.txFee} KAVA"
)
}
// 区块高度(如果有)
transaction.blockNumber?.let { blockNum ->
DetailRow(
label = "区块高度",
value = blockNum.toString()
)
}
// 时间
DetailRow(
label = "发起时间",
value = formatDateTime(transaction.createdAt)
)
transaction.confirmedAt?.let { confirmedAt ->
DetailRow(
label = "确认时间",
value = formatDateTime(confirmedAt)
)
}
// 备注(如果有)
if (transaction.note.isNotEmpty()) {
DetailRow(
label = "备注",
value = transaction.note
)
}
}
},
confirmButton = {
Row {
// 在浏览器中查看
TextButton(
onClick = {
val explorerUrl = getExplorerUrl(transaction.txHash, networkType)
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(explorerUrl))
context.startActivity(intent)
}
) {
Icon(Icons.Default.OpenInNew, contentDescription = null, modifier = Modifier.size(16.dp))
Spacer(modifier = Modifier.width(4.dp))
Text("浏览器查看")
}
TextButton(onClick = onDismiss) {
Text("关闭")
}
}
}
)
}
/**
* 详情行
*/
@Composable
private fun DetailRow(
label: String,
value: String,
isAddress: Boolean = false,
onCopy: (() -> Unit)? = null
) {
Column {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(2.dp))
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = if (isAddress) formatAddressLong(value) else value,
style = MaterialTheme.typography.bodyMedium,
fontFamily = if (isAddress) FontFamily.Monospace else FontFamily.Default,
modifier = Modifier.weight(1f),
maxLines = if (isAddress) 2 else Int.MAX_VALUE
)
if (onCopy != null) {
IconButton(
onClick = onCopy,
modifier = Modifier.size(24.dp)
) {
Icon(
imageVector = Icons.Default.ContentCopy,
contentDescription = "复制",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
}
}
}
}
}
// ========== 辅助函数 ==========
/**
* 格式化地址显示简短形式
*/
private fun formatAddress(address: String): String {
return if (address.length > 16) {
"${address.take(8)}...${address.takeLast(6)}"
} else {
address
}
}
/**
* 格式化地址显示长形式用于详情
*/
private fun formatAddressLong(address: String): String {
return if (address.length > 20) {
"${address.take(10)}...${address.takeLast(8)}"
} else {
address
}
}
/**
* 获取代币符号
*/
private fun getTokenSymbol(tokenType: TokenType): String {
return when (tokenType) {
TokenType.KAVA -> "KAVA"
TokenType.GREEN_POINTS -> "绿积分"
}
}
/**
* 格式化日期时间
*/
private fun formatDateTime(timestamp: Long): String {
val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault())
return sdf.format(Date(timestamp))
}
/**
* 获取区块链浏览器 URL
*/
private fun getExplorerUrl(txHash: String, networkType: NetworkType): String {
return when (networkType) {
NetworkType.MAINNET -> "https://kavascan.com/tx/$txHash"
NetworkType.TESTNET -> "https://testnet.kavascan.com/tx/$txHash"
}
}

View File

@ -55,6 +55,7 @@ fun WalletsScreen(
onDeleteShare: (Long) -> Unit,
onRefreshBalance: ((String) -> Unit)? = null,
onTransfer: ((shareId: Long) -> Unit)? = null,
onViewTransactionHistory: ((shareId: Long) -> Unit)? = null, // 新增:查看转账记录
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
onImportBackup: (() -> Unit)? = null,
onCreateWallet: (() -> Unit)? = null
@ -155,6 +156,9 @@ fun WalletsScreen(
onTransfer = {
onTransfer?.invoke(share.id)
},
onViewHistory = {
onViewTransactionHistory?.invoke(share.id)
},
onDelete = { onDeleteShare(share.id) }
)
}
@ -223,6 +227,7 @@ private fun WalletItemCard(
walletBalance: WalletBalance? = null,
onViewDetails: () -> Unit,
onTransfer: () -> Unit,
onViewHistory: () -> Unit, // 新增:查看转账记录
onDelete: () -> Unit
) {
var showDeleteDialog by remember { mutableStateOf(false) }
@ -372,6 +377,16 @@ private fun WalletItemCard(
Text("转账")
}
TextButton(onClick = onViewHistory) {
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

@ -1334,7 +1334,8 @@ class MainViewModel @Inject constructor(
}
/**
* Broadcast the signed transaction
* Broadcast the signed transaction and save transaction record
* 广播签名后的交易并保存转账记录
*/
fun broadcastTransaction() {
android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called")
@ -1344,9 +1345,11 @@ class MainViewModel @Inject constructor(
val tx = _preparedTx.value
val sig = _signature.value
val transferStateValue = _transferState.value
android.util.Log.d("MainViewModel", "[BROADCAST] preparedTx: ${tx?.let { "present" } ?: "null"}")
android.util.Log.d("MainViewModel", "[BROADCAST] signature: ${sig?.let { "${it.take(20)}..." } ?: "null"}")
android.util.Log.d("MainViewModel", "[BROADCAST] transferState: shareId=${transferStateValue.shareId}, amount=${transferStateValue.amount}, tokenType=${transferStateValue.tokenType}")
if (tx == null || sig == null) {
android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig")
@ -1356,9 +1359,18 @@ class MainViewModel @Inject constructor(
val rpcUrl = _settings.value.kavaRpcUrl
android.util.Log.d("MainViewModel", "[BROADCAST] Using RPC URL: $rpcUrl")
val result = repository.broadcastTransaction(tx, sig, rpcUrl)
android.util.Log.d("MainViewModel", "[BROADCAST] repository.broadcastTransaction returned: isSuccess=${result.isSuccess}")
// 使用 broadcastTransactionAndSaveRecord 同时广播交易和保存记录
val result = repository.broadcastTransactionAndSaveRecord(
preparedTx = tx,
signature = sig,
rpcUrl = rpcUrl,
shareId = transferStateValue.shareId,
amount = transferStateValue.amount,
tokenType = transferStateValue.tokenType
)
android.util.Log.d("MainViewModel", "[BROADCAST] repository.broadcastTransactionAndSaveRecord returned: isSuccess=${result.isSuccess}")
result.fold(
onSuccess = { hash ->
@ -1374,6 +1386,69 @@ class MainViewModel @Inject constructor(
}
}
// ========== 转账记录方法 Transaction History Methods ==========
/**
* 获取指定钱包的转账记录列表Flow
* Get transaction records for a wallet as Flow (reactive)
*/
fun getTransactionRecordsFlow(shareId: Long): StateFlow<List<TransactionRecord>> {
return repository.getTransactionRecordsFlow(shareId)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
/**
* 获取指定钱包和代币类型的转账记录Flow
* Get transaction records filtered by token type
*/
fun getTransactionRecordsByTokenFlow(shareId: Long, tokenType: TokenType): StateFlow<List<TransactionRecord>> {
return repository.getTransactionRecordsByTokenFlow(shareId, tokenType)
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
}
// 转账记录分类账汇总
private val _ledgerSummary = MutableStateFlow<TransactionLedgerSummary?>(null)
val ledgerSummary: StateFlow<TransactionLedgerSummary?> = _ledgerSummary.asStateFlow()
/**
* 加载转账记录分类账汇总
* Load transaction ledger summary for a wallet
*/
fun loadLedgerSummary(shareId: Long, tokenType: TokenType) {
viewModelScope.launch {
val result = repository.getTransactionLedgerSummary(shareId, tokenType)
result.onSuccess { summary ->
_ledgerSummary.value = summary
}.onFailure { e ->
android.util.Log.e("MainViewModel", "Failed to load ledger summary: ${e.message}")
}
}
}
/**
* 清除分类账汇总缓存
* Clear ledger summary cache
*/
fun clearLedgerSummary() {
_ledgerSummary.value = null
}
/**
* 检查并更新待确认的转账记录
* Check and update pending transactions
*/
fun checkPendingTransactions() {
viewModelScope.launch {
val rpcUrl = _settings.value.kavaRpcUrl
val result = repository.checkPendingTransactions(rpcUrl)
result.onSuccess { confirmedCount ->
if (confirmedCount > 0) {
android.util.Log.d("MainViewModel", "$confirmedCount transactions confirmed")
}
}
}
}
/**
* Reset transfer state
*/

View File

@ -70,7 +70,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
// LayoutBuilder
}
/// K 线 K 线
/// K 线
void _initializeCandleWidth(double chartWidth) {
if (_initialized || widget.klines.isEmpty || chartWidth == 0) return;
@ -87,9 +87,6 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
_candleWidth = idealWidth.clamp(_minCandleWidth, _maxCandleWidth);
}
_prevCandleWidth = _candleWidth;
// K 线
_scrollToEnd();
}
/// 使 K 线
@ -577,23 +574,17 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth);
}
// K线总宽度padding
const rightPadding = 50.0; // painter rightPadding
const leftPadding = 8.0; // painter leftPadding
final availableWidth = chartWidth - rightPadding - leftPadding;
final totalWidth = widget.klines.length * _candleWidth;
// K线总宽度和可用绘图区域
const leftPadding = 8.0;
const rightPadding = 50.0;
final availableWidth = chartWidth - leftPadding - rightPadding;
final totalKlineWidth = widget.klines.length * _candleWidth;
// K线总宽度小于可用宽度时K线
// K线从左边开始画K线在 x=0K线中心在 x=(n-1)*candleWidth + candleWidth/2
// K线的中心位于屏幕中央 (availableWidth/2)
// leftOffset + (n-1)*candleWidth + candleWidth/2 = availableWidth/2
// leftOffset = availableWidth/2 - (n-1)*candleWidth - candleWidth/2
// leftOffset = availableWidth/2 - n*candleWidth + candleWidth/2
// leftOffset = (availableWidth - totalWidth + candleWidth) / 2
// K线数据稀疏leftOffset使K线靠右显示
double leftOffset = 0.0;
if (totalWidth < availableWidth) {
final lastKlineCenter = (widget.klines.length - 1) * _candleWidth + _candleWidth / 2;
leftOffset = (availableWidth / 2) - lastKlineCenter;
if (totalKlineWidth < availableWidth) {
// K线靠右leftOffset = - K线总宽度
leftOffset = availableWidth - totalKlineWidth;
}
// K线范围