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:
parent
5dfef3172e
commit
f93c7987ea
|
|
@ -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": []
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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=0,最后一根K线中心在 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线范围
|
||||
|
|
|
|||
Loading…
Reference in New Issue