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/\")",
|
||||||
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")",
|
"Bash(ssh -o ProxyJump=ceshi@103.39.231.231 ceshi@192.168.1.111 \"ls -la /home/ceshi/rwadurian/backend/services/\")",
|
||||||
"Bash(where:*)",
|
"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": [],
|
"deny": [],
|
||||||
"ask": []
|
"ask": []
|
||||||
|
|
|
||||||
|
|
@ -256,6 +256,10 @@ fun TssPartyApp(
|
||||||
transferWalletId = shareId
|
transferWalletId = shareId
|
||||||
navController.navigate("transfer/$shareId")
|
navController.navigate("transfer/$shareId")
|
||||||
},
|
},
|
||||||
|
onViewTransactionHistory = { shareId ->
|
||||||
|
// 导航到转账历史页面
|
||||||
|
navController.navigate("transaction_history/$shareId")
|
||||||
|
},
|
||||||
onExportBackup = { shareId, _ ->
|
onExportBackup = { shareId, _ ->
|
||||||
// Get address for filename
|
// Get address for filename
|
||||||
val share = shares.find { it.id == shareId }
|
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 (创建钱包)
|
// Tab 2: Create Wallet (创建钱包)
|
||||||
composable(BottomNavItem.Create.route) {
|
composable(BottomNavItem.Create.route) {
|
||||||
CreateWalletScreen(
|
CreateWalletScreen(
|
||||||
|
|
|
||||||
|
|
@ -93,15 +93,210 @@ interface AppSettingDao {
|
||||||
suspend fun setValue(setting: AppSettingEntity)
|
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
|
* Room database
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [ShareRecordEntity::class, AppSettingEntity::class],
|
entities = [ShareRecordEntity::class, AppSettingEntity::class, TransactionRecordEntity::class],
|
||||||
version = 3, // Version 3: added party_id column to share_records
|
version = 4, // Version 4: added transaction_records table for transfer history
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
abstract class TssDatabase : RoomDatabase() {
|
abstract class TssDatabase : RoomDatabase() {
|
||||||
abstract fun shareRecordDao(): ShareRecordDao
|
abstract fun shareRecordDao(): ShareRecordDao
|
||||||
abstract fun appSettingDao(): AppSettingDao
|
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.AppSettingEntity
|
||||||
import com.durian.tssparty.data.local.ShareRecordDao
|
import com.durian.tssparty.data.local.ShareRecordDao
|
||||||
import com.durian.tssparty.data.local.ShareRecordEntity
|
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.local.TssNativeBridge
|
||||||
import com.durian.tssparty.data.remote.GrpcClient
|
import com.durian.tssparty.data.remote.GrpcClient
|
||||||
import com.durian.tssparty.data.remote.GrpcConnectionEvent
|
import com.durian.tssparty.data.remote.GrpcConnectionEvent
|
||||||
|
|
@ -31,7 +33,8 @@ class TssRepository @Inject constructor(
|
||||||
private val grpcClient: GrpcClient,
|
private val grpcClient: GrpcClient,
|
||||||
private val tssNativeBridge: TssNativeBridge,
|
private val tssNativeBridge: TssNativeBridge,
|
||||||
private val shareRecordDao: ShareRecordDao,
|
private val shareRecordDao: ShareRecordDao,
|
||||||
private val appSettingDao: AppSettingDao
|
private val appSettingDao: AppSettingDao,
|
||||||
|
private val transactionRecordDao: TransactionRecordDao
|
||||||
) {
|
) {
|
||||||
private val _currentSession = MutableStateFlow<TssSession?>(null)
|
private val _currentSession = MutableStateFlow<TssSession?>(null)
|
||||||
val currentSession: StateFlow<TssSession?> = _currentSession.asStateFlow()
|
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 shareId The ID of the share to export
|
||||||
|
* @param includeTransactions Whether to include transaction records (default: true)
|
||||||
* @return Result containing the backup JSON string
|
* @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) {
|
return withContext(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val share = shareRecordDao.getShareById(shareId)
|
val share = shareRecordDao.getShareById(shareId)
|
||||||
?: return@withContext Result.failure(Exception("钱包不存在"))
|
?: 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)
|
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)
|
Result.success(json)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("TssRepository", "Failed to export share backup", e)
|
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
|
* @param backupJson The backup JSON string to import
|
||||||
* @return Result containing the imported ShareRecord
|
* @return Result containing the imported ShareRecord
|
||||||
*/
|
*/
|
||||||
|
|
@ -1940,7 +1957,44 @@ class TssRepository @Inject constructor(
|
||||||
val newId = shareRecordDao.insertShare(entity)
|
val newId = shareRecordDao.insertShare(entity)
|
||||||
val savedShare = shareRecord.copy(id = newId)
|
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)
|
Result.success(savedShare)
|
||||||
} catch (e: com.google.gson.JsonSyntaxException) {
|
} catch (e: com.google.gson.JsonSyntaxException) {
|
||||||
android.util.Log.e("TssRepository", "Invalid JSON format in backup", e)
|
android.util.Log.e("TssRepository", "Invalid JSON format in backup", e)
|
||||||
|
|
@ -2510,6 +2564,310 @@ class TssRepository @Inject constructor(
|
||||||
return TransactionUtils.getTransactionReceipt(txHash, rpcUrl)
|
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 ==========
|
// ========== Connection Test Methods ==========
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -2794,3 +3152,25 @@ private fun ShareRecordEntity.toShareRecord() = ShareRecord(
|
||||||
address = address,
|
address = address,
|
||||||
createdAt = createdAt
|
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 androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
import com.durian.tssparty.data.local.AppSettingDao
|
import com.durian.tssparty.data.local.AppSettingDao
|
||||||
import com.durian.tssparty.data.local.ShareRecordDao
|
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.TssDatabase
|
||||||
import com.durian.tssparty.data.local.TssNativeBridge
|
import com.durian.tssparty.data.local.TssNativeBridge
|
||||||
import com.durian.tssparty.data.remote.GrpcClient
|
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
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideGson(): Gson {
|
fun provideGson(): Gson {
|
||||||
|
|
@ -59,7 +96,7 @@ object AppModule {
|
||||||
TssDatabase::class.java,
|
TssDatabase::class.java,
|
||||||
"tss_party.db"
|
"tss_party.db"
|
||||||
)
|
)
|
||||||
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,6 +112,12 @@ object AppModule {
|
||||||
return database.appSettingDao()
|
return database.appSettingDao()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideTransactionRecordDao(database: TssDatabase): TransactionRecordDao {
|
||||||
|
return database.transactionRecordDao()
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideGrpcClient(): GrpcClient {
|
fun provideGrpcClient(): GrpcClient {
|
||||||
|
|
@ -93,8 +136,9 @@ object AppModule {
|
||||||
grpcClient: GrpcClient,
|
grpcClient: GrpcClient,
|
||||||
tssNativeBridge: TssNativeBridge,
|
tssNativeBridge: TssNativeBridge,
|
||||||
shareRecordDao: ShareRecordDao,
|
shareRecordDao: ShareRecordDao,
|
||||||
appSettingDao: AppSettingDao
|
appSettingDao: AppSettingDao,
|
||||||
|
transactionRecordDao: TransactionRecordDao
|
||||||
): TssRepository {
|
): 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
|
* Share backup data for export/import
|
||||||
* Contains all necessary information to restore a wallet share
|
* 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(
|
data class ShareBackup(
|
||||||
@SerializedName("version")
|
@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")
|
@SerializedName("sessionId")
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
|
|
@ -196,14 +201,18 @@ data class ShareBackup(
|
||||||
val createdAt: Long,
|
val createdAt: Long,
|
||||||
|
|
||||||
@SerializedName("exportedAt")
|
@SerializedName("exportedAt")
|
||||||
val exportedAt: Long = System.currentTimeMillis()
|
val exportedAt: Long = System.currentTimeMillis(),
|
||||||
|
|
||||||
|
@SerializedName("transactionRecords")
|
||||||
|
val transactionRecords: List<TransactionRecordBackup>? = null // 转账记录(可选,v3新增)
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
const val FILE_EXTENSION = "tss-backup"
|
const val FILE_EXTENSION = "tss-backup"
|
||||||
const val MIME_TYPE = "application/octet-stream"
|
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 {
|
fun fromShareRecord(share: ShareRecord): ShareBackup {
|
||||||
return ShareBackup(
|
return ShareBackup(
|
||||||
|
|
@ -218,6 +227,28 @@ data class ShareBackup(
|
||||||
createdAt = share.createdAt
|
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
|
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,
|
onDeleteShare: (Long) -> Unit,
|
||||||
onRefreshBalance: ((String) -> Unit)? = null,
|
onRefreshBalance: ((String) -> Unit)? = null,
|
||||||
onTransfer: ((shareId: Long) -> Unit)? = null,
|
onTransfer: ((shareId: Long) -> Unit)? = null,
|
||||||
|
onViewTransactionHistory: ((shareId: Long) -> Unit)? = null, // 新增:查看转账记录
|
||||||
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
onExportBackup: ((shareId: Long, password: String) -> Unit)? = null,
|
||||||
onImportBackup: (() -> Unit)? = null,
|
onImportBackup: (() -> Unit)? = null,
|
||||||
onCreateWallet: (() -> Unit)? = null
|
onCreateWallet: (() -> Unit)? = null
|
||||||
|
|
@ -155,6 +156,9 @@ fun WalletsScreen(
|
||||||
onTransfer = {
|
onTransfer = {
|
||||||
onTransfer?.invoke(share.id)
|
onTransfer?.invoke(share.id)
|
||||||
},
|
},
|
||||||
|
onViewHistory = {
|
||||||
|
onViewTransactionHistory?.invoke(share.id)
|
||||||
|
},
|
||||||
onDelete = { onDeleteShare(share.id) }
|
onDelete = { onDeleteShare(share.id) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -223,6 +227,7 @@ private fun WalletItemCard(
|
||||||
walletBalance: WalletBalance? = null,
|
walletBalance: WalletBalance? = null,
|
||||||
onViewDetails: () -> Unit,
|
onViewDetails: () -> Unit,
|
||||||
onTransfer: () -> Unit,
|
onTransfer: () -> Unit,
|
||||||
|
onViewHistory: () -> Unit, // 新增:查看转账记录
|
||||||
onDelete: () -> Unit
|
onDelete: () -> Unit
|
||||||
) {
|
) {
|
||||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||||
|
|
@ -372,6 +377,16 @@ private fun WalletItemCard(
|
||||||
Text("转账")
|
Text("转账")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton(onClick = onViewHistory) {
|
||||||
|
Icon(
|
||||||
|
Icons.Default.Receipt,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp)
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Text("记录")
|
||||||
|
}
|
||||||
|
|
||||||
TextButton(
|
TextButton(
|
||||||
onClick = { showDeleteDialog = true },
|
onClick = { showDeleteDialog = true },
|
||||||
colors = ButtonDefaults.textButtonColors(
|
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() {
|
fun broadcastTransaction() {
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called")
|
android.util.Log.d("MainViewModel", "[BROADCAST] broadcastTransaction() called")
|
||||||
|
|
@ -1344,9 +1345,11 @@ class MainViewModel @Inject constructor(
|
||||||
|
|
||||||
val tx = _preparedTx.value
|
val tx = _preparedTx.value
|
||||||
val sig = _signature.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] preparedTx: ${tx?.let { "present" } ?: "null"}")
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] signature: ${sig?.let { "${it.take(20)}..." } ?: "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) {
|
if (tx == null || sig == null) {
|
||||||
android.util.Log.e("MainViewModel", "[BROADCAST] Missing tx or signature! tx=$tx, sig=$sig")
|
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
|
val rpcUrl = _settings.value.kavaRpcUrl
|
||||||
android.util.Log.d("MainViewModel", "[BROADCAST] Using RPC URL: $rpcUrl")
|
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(
|
result.fold(
|
||||||
onSuccess = { hash ->
|
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
|
* Reset transfer state
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -70,7 +70,7 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
|
||||||
// 初始化会在 LayoutBuilder 中完成
|
// 初始化会在 LayoutBuilder 中完成
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 初始化 K 线宽度,让所有 K 线占满屏幕
|
/// 初始化 K 线宽度
|
||||||
void _initializeCandleWidth(double chartWidth) {
|
void _initializeCandleWidth(double chartWidth) {
|
||||||
if (_initialized || widget.klines.isEmpty || chartWidth == 0) return;
|
if (_initialized || widget.klines.isEmpty || chartWidth == 0) return;
|
||||||
|
|
||||||
|
|
@ -87,9 +87,6 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
|
||||||
_candleWidth = idealWidth.clamp(_minCandleWidth, _maxCandleWidth);
|
_candleWidth = idealWidth.clamp(_minCandleWidth, _maxCandleWidth);
|
||||||
}
|
}
|
||||||
_prevCandleWidth = _candleWidth;
|
_prevCandleWidth = _candleWidth;
|
||||||
|
|
||||||
// 首次加载时让最新 K 线靠右显示
|
|
||||||
_scrollToEnd();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 滚动使最新 K 线居中显示
|
/// 滚动使最新 K 线居中显示
|
||||||
|
|
@ -577,23 +574,17 @@ class _KlineChartWidgetState extends State<KlineChartWidget> {
|
||||||
return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth);
|
return _VisibleData(klines: [], startIndex: 0, candleWidth: _candleWidth);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算K线总宽度(不包含左右padding)
|
// 计算K线总宽度和可用绘图区域
|
||||||
const rightPadding = 50.0; // 与 painter 中的 rightPadding 保持一致
|
const leftPadding = 8.0;
|
||||||
const leftPadding = 8.0; // 与 painter 中的 leftPadding 保持一致
|
const rightPadding = 50.0;
|
||||||
final availableWidth = chartWidth - rightPadding - leftPadding;
|
final availableWidth = chartWidth - leftPadding - rightPadding;
|
||||||
final totalWidth = widget.klines.length * _candleWidth;
|
final totalKlineWidth = widget.klines.length * _candleWidth;
|
||||||
|
|
||||||
// 计算左侧偏移量:当K线总宽度小于可用宽度时,让最新K线(最右边)居中显示
|
// 当K线数据稀疏(总宽度小于可用宽度)时,计算leftOffset使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
|
|
||||||
double leftOffset = 0.0;
|
double leftOffset = 0.0;
|
||||||
if (totalWidth < availableWidth) {
|
if (totalKlineWidth < availableWidth) {
|
||||||
final lastKlineCenter = (widget.klines.length - 1) * _candleWidth + _candleWidth / 2;
|
// K线靠右:leftOffset = 可用宽度 - K线总宽度
|
||||||
leftOffset = (availableWidth / 2) - lastKlineCenter;
|
leftOffset = availableWidth - totalKlineWidth;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 根据滚动位置计算可见的K线范围
|
// 根据滚动位置计算可见的K线范围
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue