refactor: extract HistoryScreen into separate components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -22,8 +22,6 @@ import kotlinx.coroutines.flow.stateIn
|
|||||||
import kotlinx.coroutines.flow.update
|
import kotlinx.coroutines.flow.update
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
// ── Filter model ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
data class PaymentFilter(
|
data class PaymentFilter(
|
||||||
val searchQuery : String = "",
|
val searchQuery : String = "",
|
||||||
val direction : DirectionFilter = DirectionFilter.ALL,
|
val direction : DirectionFilter = DirectionFilter.ALL,
|
||||||
@@ -37,8 +35,7 @@ data class PaymentFilter(
|
|||||||
)
|
)
|
||||||
|
|
||||||
val PaymentFilter.isActive: Boolean
|
val PaymentFilter.isActive: Boolean
|
||||||
get() = searchQuery.isNotBlank()
|
get() = direction != DirectionFilter.ALL
|
||||||
|| direction != DirectionFilter.ALL
|
|
||||||
|| statuses.isNotEmpty()
|
|| statuses.isNotEmpty()
|
||||||
|| types.isNotEmpty()
|
|| types.isNotEmpty()
|
||||||
|| minAmountSat != null
|
|| minAmountSat != null
|
||||||
@@ -50,8 +47,6 @@ enum class DirectionFilter { ALL, OUTGOING, INCOMING }
|
|||||||
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
enum class StatusFilter { COMPLETED, PENDING, FAILED }
|
||||||
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
enum class DatePreset { THIS_WEEK, THIS_MONTH, THIS_YEAR }
|
||||||
|
|
||||||
|
|
||||||
// ── ViewModel ─────────────────────────────────────────────────────────────────
|
|
||||||
@OptIn(FlowPreview::class)
|
@OptIn(FlowPreview::class)
|
||||||
class HistoryViewModel(
|
class HistoryViewModel(
|
||||||
private val repo : WalletRepository,
|
private val repo : WalletRepository,
|
||||||
@@ -65,7 +60,6 @@ class HistoryViewModel(
|
|||||||
val state: StateFlow<HistoryState> = _state
|
val state: StateFlow<HistoryState> = _state
|
||||||
|
|
||||||
// ── Filter state (search is just another filter field) ────────────────────
|
// ── Filter state (search is just another filter field) ────────────────────
|
||||||
|
|
||||||
private val _filter = MutableStateFlow(PaymentFilter())
|
private val _filter = MutableStateFlow(PaymentFilter())
|
||||||
val filter: StateFlow<PaymentFilter> = _filter
|
val filter: StateFlow<PaymentFilter> = _filter
|
||||||
|
|
||||||
@@ -90,9 +84,6 @@ class HistoryViewModel(
|
|||||||
fun setAmountFilter(min: Long?, max: Long?) {
|
fun setAmountFilter(min: Long?, max: Long?) {
|
||||||
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
_filter.update { it.copy(minAmountSat = min, maxAmountSat = max) }
|
||||||
}
|
}
|
||||||
fun clearStatusFilter() { _filter.update { it.copy(statuses = emptySet()) } }
|
|
||||||
fun clearTypeFilter() { _filter.update { it.copy(types = emptySet()) } }
|
|
||||||
|
|
||||||
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
fun setDateFilter(min: Long?, max: Long?, preset: DatePreset? = null) {
|
||||||
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
_filter.update { it.copy(minCreatedAt = min, maxCreatedAt = max, datePreset = preset) }
|
||||||
}
|
}
|
||||||
@@ -193,56 +184,33 @@ class HistoryViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── filteredState ─────────────────────────────────────────────────────────
|
// ── filteredState ─────────────────────────────────────────────────────────
|
||||||
val filteredState: StateFlow<HistoryState> = combine(
|
val filteredState = combine(_state, _filter, _roomResults) { s, f, roomResults ->
|
||||||
_state, _filter, _roomResults
|
|
||||||
) { s, f, roomResults ->
|
|
||||||
|
|
||||||
if (s !is HistoryState.Success) return@combine s
|
if (s !is HistoryState.Success) return@combine s
|
||||||
|
|
||||||
// ── Path A: pure in-memory ────────────────────────────────────────────
|
|
||||||
if (roomResults == null) {
|
if (roomResults == null) {
|
||||||
Log.d(TAG, "FILTER [IN-MEMORY] filteredState: base=${s.payments.size} payments")
|
s.copy(payments = s.payments.applyInMemoryFilters(f))
|
||||||
val filtered = s.payments
|
} else {
|
||||||
.distinctBy { it.paymentHash }
|
s.copy(payments = roomResults.applyInMemoryFilters(f), canLoadMore = false)
|
||||||
.let { list ->
|
|
||||||
when (f.direction) {
|
|
||||||
DirectionFilter.ALL -> list
|
|
||||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
|
||||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
.let { list ->
|
|
||||||
if (f.types.isEmpty()) list
|
|
||||||
else list.filter { it.extra?.tag in f.types }
|
|
||||||
}
|
|
||||||
Log.d(TAG, "FILTER [IN-MEMORY] filteredState: after filters → ${filtered.size} shown")
|
|
||||||
return@combine s.copy(payments = filtered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Path B: Room results — apply direction + type in-memory ───────────
|
|
||||||
Log.d(TAG, "FILTER [ROOM ] filteredState: base=${roomResults.size} from Room")
|
|
||||||
val filtered = roomResults
|
|
||||||
.distinctBy { it.paymentHash }
|
|
||||||
.let { list ->
|
|
||||||
when (f.direction) {
|
|
||||||
DirectionFilter.ALL -> list
|
|
||||||
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
|
||||||
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.let { list ->
|
|
||||||
if (f.types.isEmpty()) list
|
|
||||||
else list.filter { it.extra?.tag in f.types }
|
|
||||||
}
|
|
||||||
|
|
||||||
Log.d(TAG, "FILTER [ROOM ] filteredState: after dir+type → ${filtered.size} shown")
|
|
||||||
s.copy(payments = filtered, canLoadMore = false)
|
|
||||||
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
}.stateIn(viewModelScope, SharingStarted.Eagerly, HistoryState.Loading)
|
||||||
|
|
||||||
private var currentOffset = 0
|
private var currentOffset = 0
|
||||||
private var loadJob: Job? = null
|
private var loadJob: Job? = null
|
||||||
|
|
||||||
|
private fun List<PaymentRecord>.applyInMemoryFilters(f: PaymentFilter) =
|
||||||
|
distinctBy { it.paymentHash }
|
||||||
|
.let { list ->
|
||||||
|
when (f.direction) {
|
||||||
|
DirectionFilter.ALL -> list
|
||||||
|
DirectionFilter.OUTGOING -> list.filter { it.isOutgoing }
|
||||||
|
DirectionFilter.INCOMING -> list.filter { !it.isOutgoing }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.let { list ->
|
||||||
|
if (f.types.isEmpty()) list
|
||||||
|
else list.filter { it.extra?.tag in f.types }
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
|
private suspend fun syncNewPayments(maxPayments: Int = WalletConstants.PAYMENTS_SYNC_CHECKPOINT) {
|
||||||
var syncOffset = 0
|
var syncOffset = 0
|
||||||
var totalNew = 0
|
var totalNew = 0
|
||||||
@@ -445,14 +413,8 @@ class HistoryViewModel(
|
|||||||
|
|
||||||
fun clearDetail() { _detailState.value = DetailState.Idle }
|
fun clearDetail() { _detailState.value = DetailState.Idle }
|
||||||
|
|
||||||
// ── Destination resolution ────────────────────────────────────────────────
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "HistoryViewModel"
|
private const val TAG = "HistoryViewModel"
|
||||||
|
|
||||||
fun isCompletedStatus(s: String) = s.lowercase() in setOf("success", "complete", "paid")
|
|
||||||
fun isPendingStatus(s: String) = s.lowercase() in setOf("pending", "in_flight", "inflight")
|
|
||||||
fun isFailedStatus(s: String) = s.lowercase() in setOf("failed", "error", "expired")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,8 @@ import androidx.navigation.compose.currentBackStackEntryAsState
|
|||||||
import androidx.navigation.compose.rememberNavController
|
import androidx.navigation.compose.rememberNavController
|
||||||
import androidx.navigation.navArgument
|
import androidx.navigation.navArgument
|
||||||
import com.bitcointxoko.gudariwallet.MainActivity
|
import com.bitcointxoko.gudariwallet.MainActivity
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.history.HistoryScreen
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.history.PaymentDetailScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveScreen
|
||||||
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
import com.bitcointxoko.gudariwallet.ui.send.SendScreen
|
||||||
import com.bitcointxoko.gudariwallet.util.SendInputType
|
import com.bitcointxoko.gudariwallet.util.SendInputType
|
||||||
|
|||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.material3.DatePicker
|
||||||
|
import androidx.compose.material3.DatePickerDialog
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.FilterChip
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.OutlinedButton
|
||||||
|
import androidx.compose.material3.OutlinedTextField
|
||||||
|
import androidx.compose.material3.SheetState
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun FilterBottomSheet(
|
||||||
|
currentFilter : PaymentFilter,
|
||||||
|
availableTypes : List<String>,
|
||||||
|
sheetState : SheetState,
|
||||||
|
onDismiss : () -> Unit,
|
||||||
|
onDirectionSelected : (DirectionFilter) -> Unit,
|
||||||
|
onStatusToggled : (StatusFilter) -> Unit,
|
||||||
|
onTypeToggled : (String) -> Unit,
|
||||||
|
onAmountFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||||
|
onDatePresetSelected: (DatePreset) -> Unit,
|
||||||
|
onDateFilterSet : (min: Long?, max: Long?) -> Unit,
|
||||||
|
onDateClearRequested: () -> Unit
|
||||||
|
) {
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 32.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Filter payments",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ── Direction ────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Direction",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DirectionFilter.entries.forEach { option ->
|
||||||
|
val label = when (option) {
|
||||||
|
DirectionFilter.ALL -> "All"
|
||||||
|
DirectionFilter.OUTGOING -> "Outgoing"
|
||||||
|
DirectionFilter.INCOMING -> "Incoming"
|
||||||
|
}
|
||||||
|
FilterChip(
|
||||||
|
selected = currentFilter.direction == option,
|
||||||
|
onClick = { onDirectionSelected(option) },
|
||||||
|
label = { Text(label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ── Status ───────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Status",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
StatusFilter.entries.forEach { option ->
|
||||||
|
FilterChip(
|
||||||
|
selected = option in currentFilter.statuses,
|
||||||
|
onClick = { onStatusToggled(option) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
when (option) {
|
||||||
|
StatusFilter.COMPLETED -> "Completed"
|
||||||
|
StatusFilter.PENDING -> "Pending"
|
||||||
|
StatusFilter.FAILED -> "Failed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Type (dynamic, only shown when tags exist) ───────────────────
|
||||||
|
if (availableTypes.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
Text(
|
||||||
|
text = "Type",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
availableTypes.forEach { type ->
|
||||||
|
FilterChip(
|
||||||
|
selected = type in currentFilter.types,
|
||||||
|
onClick = { onTypeToggled(type) },
|
||||||
|
label = { Text(type.uppercase()) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ── Amount (sats) ────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Amount (sats)",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
var minText by rememberSaveable { mutableStateOf(currentFilter.minAmountSat?.toString() ?: "") }
|
||||||
|
var maxText by rememberSaveable { mutableStateOf(currentFilter.maxAmountSat?.toString() ?: "") }
|
||||||
|
|
||||||
|
val commitAmount = {
|
||||||
|
val min = minText.trim().toLongOrNull()
|
||||||
|
val max = maxText.trim().toLongOrNull()
|
||||||
|
onAmountFilterSet(min, max)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedTextField(
|
||||||
|
value = minText,
|
||||||
|
onValueChange = { minText = it.filter(Char::isDigit); commitAmount() },
|
||||||
|
label = { Text("Min") },
|
||||||
|
suffix = { Text("sats") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
OutlinedTextField(
|
||||||
|
value = maxText,
|
||||||
|
onValueChange = { maxText = it.filter(Char::isDigit); commitAmount() },
|
||||||
|
label = { Text("Max") },
|
||||||
|
suffix = { Text("sats") },
|
||||||
|
singleLine = true,
|
||||||
|
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
// ── Date ─────────────────────────────────────────────────────────
|
||||||
|
Text(
|
||||||
|
text = "Date",
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Preset chips
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
|
DatePreset.entries.forEach { preset ->
|
||||||
|
val label = when (preset) {
|
||||||
|
DatePreset.THIS_WEEK -> "This week"
|
||||||
|
DatePreset.THIS_MONTH -> "This month"
|
||||||
|
DatePreset.THIS_YEAR -> "This year"
|
||||||
|
}
|
||||||
|
FilterChip(
|
||||||
|
selected = currentFilter.datePreset == preset,
|
||||||
|
onClick = {
|
||||||
|
if (currentFilter.datePreset == preset) onDateClearRequested()
|
||||||
|
else onDatePresetSelected(preset)
|
||||||
|
},
|
||||||
|
label = { Text(label) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Manual From / To date pickers
|
||||||
|
var showFromPicker by remember { mutableStateOf(false) }
|
||||||
|
var showToPicker by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showFromPicker = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(currentFilter.minCreatedAt?.let { formatEpochShort(it) } ?: "From")
|
||||||
|
}
|
||||||
|
Text("–", style = MaterialTheme.typography.bodyLarge)
|
||||||
|
OutlinedButton(
|
||||||
|
onClick = { showToPicker = true },
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
) {
|
||||||
|
Text(currentFilter.maxCreatedAt?.let { formatEpochShort(it) } ?: "To")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showFromPicker) {
|
||||||
|
val pickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = currentFilter.minCreatedAt?.let { it * 1000 }
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showFromPicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
val epochSec = pickerState.selectedDateMillis?.div(1000)
|
||||||
|
onDateFilterSet(epochSec, currentFilter.maxCreatedAt)
|
||||||
|
showFromPicker = false
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showFromPicker = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
) { DatePicker(state = pickerState) }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showToPicker) {
|
||||||
|
val pickerState = rememberDatePickerState(
|
||||||
|
initialSelectedDateMillis = currentFilter.maxCreatedAt?.let { it * 1000 }
|
||||||
|
)
|
||||||
|
DatePickerDialog(
|
||||||
|
onDismissRequest = { showToPicker = false },
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = {
|
||||||
|
// +86399 to make "To" date inclusive (end of day)
|
||||||
|
val epochSec = pickerState.selectedDateMillis?.div(1000)?.let { it + 86399L }
|
||||||
|
onDateFilterSet(currentFilter.minCreatedAt, epochSec)
|
||||||
|
showToPicker = false
|
||||||
|
}) { Text("OK") }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = { showToPicker = false }) { Text("Cancel") }
|
||||||
|
}
|
||||||
|
) { DatePicker(state = pickerState) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
|
||||||
|
// ── Amount + fiat column ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun AmountWithFiatColumn(
|
||||||
|
amountSat : Long,
|
||||||
|
isOutgoing : Boolean,
|
||||||
|
amountColor : Color,
|
||||||
|
fiatLine : String?,
|
||||||
|
style : TextStyle = MaterialTheme.typography.bodyMedium,
|
||||||
|
horizontalAlignment: Alignment.Horizontal = Alignment.End
|
||||||
|
) {
|
||||||
|
val prefix = if (isOutgoing) "−" else "+"
|
||||||
|
Column(horizontalAlignment = horizontalAlignment) {
|
||||||
|
Text(
|
||||||
|
text = "$prefix$amountSat sats",
|
||||||
|
style = style.copy(fontFamily = FontFamily.Monospace),
|
||||||
|
color = amountColor
|
||||||
|
)
|
||||||
|
if (fiatLine != null) {
|
||||||
|
Spacer(Modifier.height(if (horizontalAlignment == Alignment.CenterHorizontally) 4.dp else 1.dp))
|
||||||
|
Text(
|
||||||
|
text = fiatLine,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.75f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Status chip ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun StatusChip(status: String) {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
val (containerColor, contentColor) = when (status.lowercase()) {
|
||||||
|
"success", "complete", "paid" ->
|
||||||
|
semantic.successContainer to semantic.onSuccessContainer
|
||||||
|
"pending", "in_flight", "inflight" ->
|
||||||
|
semantic.warningContainer to semantic.onWarningContainer
|
||||||
|
"failed", "error", "expired" ->
|
||||||
|
semantic.errorContainer to semantic.onErrorContainer
|
||||||
|
else ->
|
||||||
|
MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
}
|
||||||
|
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.small,
|
||||||
|
color = containerColor,
|
||||||
|
contentColor = contentColor
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = status.replaceFirstChar { it.uppercase() },
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Section card wrapper ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DetailSection(
|
||||||
|
title : String,
|
||||||
|
content: @Composable ColumnScope.() -> Unit
|
||||||
|
) {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(modifier = Modifier.padding(16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Label / value row ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun DetailRow(
|
||||||
|
label : String,
|
||||||
|
value : String,
|
||||||
|
monospace : Boolean = false,
|
||||||
|
copyable : Boolean = false,
|
||||||
|
onCopy : (() -> Unit)? = null,
|
||||||
|
maxLines : Int = Int.MAX_VALUE,
|
||||||
|
valueColor : Color = Color.Unspecified,
|
||||||
|
textDecoration : TextDecoration? = null,
|
||||||
|
onValueClick : (() -> Unit)? = null,
|
||||||
|
trailingContent: (@Composable () -> Unit)? = null
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(if (copyable && onCopy != null) Modifier.clickable(onClick = onCopy) else Modifier)
|
||||||
|
.padding(vertical = 6.dp),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.Top
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.weight(0.35f)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.weight(0.65f),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = value,
|
||||||
|
style = if (monospace)
|
||||||
|
MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, textDecoration = textDecoration)
|
||||||
|
else
|
||||||
|
MaterialTheme.typography.bodySmall.copy(textDecoration = textDecoration),
|
||||||
|
color = valueColor,
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f, fill = false)
|
||||||
|
.then(if (onValueClick != null) Modifier.clickable(onClick = onValueClick) else Modifier),
|
||||||
|
maxLines = maxLines,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
if (copyable) {
|
||||||
|
Spacer(Modifier.width(4.dp))
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy $label",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
trailingContent?.invoke()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,503 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.FlowRow
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentPaste
|
||||||
|
import androidx.compose.material.icons.filled.FilterList
|
||||||
|
import androidx.compose.material.icons.filled.Info
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material3.Badge
|
||||||
|
import androidx.compose.material3.BadgedBox
|
||||||
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.InputChip
|
||||||
|
import androidx.compose.material3.LocalContentColor
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextField
|
||||||
|
import androidx.compose.material3.TextFieldDefaults
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.derivedStateOf
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
|
import androidx.compose.ui.focus.focusRequester
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DatePreset
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DirectionFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.HistoryState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.PaymentFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.StatusFilter
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── List screen ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun HistoryScreen(
|
||||||
|
vm : HistoryViewModel,
|
||||||
|
onClose : () -> Unit,
|
||||||
|
onPaymentClick : (PaymentRecord) -> Unit
|
||||||
|
) {
|
||||||
|
val state by vm.filteredState.collectAsState()
|
||||||
|
val filter by vm.filter.collectAsState()
|
||||||
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
val availableTypes by vm.availableTypes.collectAsState()
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
var filterSheetVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
val dismissFilterSheet = { filterSheetVisible = false }
|
||||||
|
var searchVisible by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var searchInfoSheetVisible by remember { mutableStateOf(false) }
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
|
||||||
|
val isFiltered = filter.isActive
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text("History") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onClose) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
BadgedBox(
|
||||||
|
badge = { if (filter.searchQuery.isNotBlank()) Badge() }
|
||||||
|
) {
|
||||||
|
IconButton(onClick = {
|
||||||
|
searchVisible = !searchVisible
|
||||||
|
if (!searchVisible) vm.clearSearch()
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Search,
|
||||||
|
contentDescription = "Search payments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BadgedBox(
|
||||||
|
badge = { if (isFiltered) Badge() }
|
||||||
|
) {
|
||||||
|
IconButton(onClick = { filterSheetVisible = true }) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.FilterList,
|
||||||
|
contentDescription = "Filter payments"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
val focusRequester = remember { FocusRequester() }
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
LaunchedEffect(searchVisible) {
|
||||||
|
if (searchVisible) focusRequester.requestFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
) {
|
||||||
|
// ── Search bar ───────────────────────────────────────────────────
|
||||||
|
if (searchVisible) {
|
||||||
|
SearchBar(
|
||||||
|
query = filter.searchQuery,
|
||||||
|
focusRequester = focusRequester,
|
||||||
|
clipboard = clipboard,
|
||||||
|
scope = scope,
|
||||||
|
onQueryChange = { vm.setSearchQuery(it) },
|
||||||
|
onClear = { vm.clearSearch() },
|
||||||
|
onInfoClick = { searchInfoSheetVisible = true }
|
||||||
|
)
|
||||||
|
HorizontalDivider()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active filter chips ──────────────────────────────────────────
|
||||||
|
val searchActive = filter.searchQuery.isNotBlank()
|
||||||
|
if (isFiltered || searchActive) {
|
||||||
|
ActiveFilterChips(
|
||||||
|
filter = filter,
|
||||||
|
searchActive = searchActive,
|
||||||
|
onClearDirection = { vm.setDirectionFilter(DirectionFilter.ALL) },
|
||||||
|
onClearStatus = { vm.toggleStatusFilter(it) },
|
||||||
|
onClearType = { vm.toggleTypeFilter(it) },
|
||||||
|
onClearAmount = { vm.setAmountFilter(null, null) },
|
||||||
|
onClearDate = { vm.clearDateFilter() },
|
||||||
|
onClearSearch = {
|
||||||
|
vm.clearSearch()
|
||||||
|
searchVisible = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Content ──────────────────────────────────────────────────────
|
||||||
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
when (val s = state) {
|
||||||
|
|
||||||
|
is HistoryState.Loading -> {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||||
|
}
|
||||||
|
|
||||||
|
is HistoryState.Error -> {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = s.message,
|
||||||
|
color = MaterialTheme.colorScheme.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
Button(onClick = { vm.refresh() }) { Text("Retry") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
is HistoryState.Success -> {
|
||||||
|
val shouldLoadMore by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val lastVisible = listState.layoutInfo.visibleItemsInfo.lastOrNull()
|
||||||
|
val totalItems = listState.layoutInfo.totalItemsCount
|
||||||
|
lastVisible != null && lastVisible.index >= totalItems - 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(shouldLoadMore) {
|
||||||
|
if (shouldLoadMore && s.canLoadMore) vm.loadMore()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (s.payments.isEmpty()) {
|
||||||
|
Text(
|
||||||
|
text = "No payments yet.",
|
||||||
|
modifier = Modifier.align(Alignment.Center),
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
PullToRefreshBox(
|
||||||
|
isRefreshing = s.isRefreshing,
|
||||||
|
onRefresh = { vm.refresh() }
|
||||||
|
) {
|
||||||
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp)
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
items = s.payments,
|
||||||
|
key = { it.paymentHash }
|
||||||
|
) { payment ->
|
||||||
|
PaymentRow(
|
||||||
|
payment = payment,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
onClick = { onPaymentClick(payment) }
|
||||||
|
)
|
||||||
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
|
thickness = 0.5.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (s.canLoadMore) {
|
||||||
|
item {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Sheets (outside Scaffold so they overlay correctly) ──────────────────
|
||||||
|
if (filterSheetVisible) {
|
||||||
|
FilterBottomSheet(
|
||||||
|
currentFilter = filter,
|
||||||
|
availableTypes = availableTypes,
|
||||||
|
sheetState = sheetState,
|
||||||
|
onDismiss = dismissFilterSheet,
|
||||||
|
onDirectionSelected = { direction ->
|
||||||
|
vm.setDirectionFilter(direction)
|
||||||
|
dismissFilterSheet()
|
||||||
|
},
|
||||||
|
onStatusToggled = { vm.toggleStatusFilter(it) },
|
||||||
|
onTypeToggled = { vm.toggleTypeFilter(it) },
|
||||||
|
onAmountFilterSet = { min, max -> vm.setAmountFilter(min, max) },
|
||||||
|
onDatePresetSelected = { vm.applyDatePreset(it) },
|
||||||
|
onDateFilterSet = { min, max -> vm.setDateFilter(min, max, preset = null) },
|
||||||
|
onDateClearRequested = { vm.clearDateFilter() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
SearchInfoSheet(
|
||||||
|
visible = searchInfoSheetVisible,
|
||||||
|
onDismiss = { searchInfoSheetVisible = false }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search bar ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchBar(
|
||||||
|
query : String,
|
||||||
|
focusRequester : FocusRequester,
|
||||||
|
clipboard : androidx.compose.ui.platform.Clipboard,
|
||||||
|
scope : kotlinx.coroutines.CoroutineScope,
|
||||||
|
onQueryChange : (String) -> Unit,
|
||||||
|
onClear : () -> Unit,
|
||||||
|
onInfoClick : () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
IconButton(onClick = onInfoClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Info,
|
||||||
|
contentDescription = "Search help"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = onQueryChange,
|
||||||
|
placeholder = { Text("Search transactions") },
|
||||||
|
singleLine = true,
|
||||||
|
colors = TextFieldDefaults.colors(
|
||||||
|
focusedContainerColor = Color.Transparent,
|
||||||
|
unfocusedContainerColor = Color.Transparent,
|
||||||
|
focusedIndicatorColor = Color.Transparent,
|
||||||
|
unfocusedIndicatorColor = Color.Transparent
|
||||||
|
),
|
||||||
|
modifier = Modifier
|
||||||
|
.weight(1f)
|
||||||
|
.focusRequester(focusRequester)
|
||||||
|
)
|
||||||
|
IconButton(onClick = {
|
||||||
|
scope.launch {
|
||||||
|
val pasted = clipboard.getClipEntry()
|
||||||
|
?.clipData?.getItemAt(0)?.text?.toString().orEmpty()
|
||||||
|
onQueryChange(pasted)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentPaste,
|
||||||
|
contentDescription = "Paste"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onClear,
|
||||||
|
enabled = query.isNotEmpty()
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
tint = if (query.isNotEmpty()) LocalContentColor.current
|
||||||
|
else MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active filter chips ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
private fun ActiveFilterChips(
|
||||||
|
filter : PaymentFilter,
|
||||||
|
searchActive : Boolean,
|
||||||
|
onClearDirection: () -> Unit,
|
||||||
|
onClearStatus : (StatusFilter) -> Unit,
|
||||||
|
onClearType : (String) -> Unit,
|
||||||
|
onClearAmount : () -> Unit,
|
||||||
|
onClearDate : () -> Unit,
|
||||||
|
onClearSearch : () -> Unit
|
||||||
|
) {
|
||||||
|
FlowRow(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 4.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||||
|
) {
|
||||||
|
if (filter.direction != DirectionFilter.ALL) {
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = onClearDirection,
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
when (filter.direction) {
|
||||||
|
DirectionFilter.OUTGOING -> "Outgoing"
|
||||||
|
else -> "Incoming"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear direction filter",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
filter.statuses.forEach { s ->
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { onClearStatus(s) },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
when (s) {
|
||||||
|
StatusFilter.COMPLETED -> "Completed"
|
||||||
|
StatusFilter.PENDING -> "Pending"
|
||||||
|
StatusFilter.FAILED -> "Failed"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Remove status filter",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
filter.types.forEach { type ->
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = { onClearType(type) },
|
||||||
|
label = { Text(type.uppercase()) },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Remove type filter",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (filter.minAmountSat != null || filter.maxAmountSat != null) {
|
||||||
|
val label = when {
|
||||||
|
filter.minAmountSat != null && filter.maxAmountSat != null ->
|
||||||
|
"${filter.minAmountSat}–${filter.maxAmountSat} sats"
|
||||||
|
filter.minAmountSat != null ->
|
||||||
|
"≥ ${filter.minAmountSat} sats"
|
||||||
|
else ->
|
||||||
|
"≤ ${filter.maxAmountSat} sats"
|
||||||
|
}
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = onClearAmount,
|
||||||
|
label = { Text(label) },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear amount filter",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val minCreatedAt = filter.minCreatedAt
|
||||||
|
val maxCreatedAt = filter.maxCreatedAt
|
||||||
|
if (minCreatedAt != null || maxCreatedAt != null) {
|
||||||
|
val label = when (filter.datePreset) {
|
||||||
|
DatePreset.THIS_WEEK -> "This week"
|
||||||
|
DatePreset.THIS_MONTH -> "This month"
|
||||||
|
DatePreset.THIS_YEAR -> "This year"
|
||||||
|
null -> when {
|
||||||
|
minCreatedAt != null && maxCreatedAt != null ->
|
||||||
|
"${formatEpochShort(minCreatedAt)}–${formatEpochShort(maxCreatedAt)}"
|
||||||
|
minCreatedAt != null ->
|
||||||
|
"From ${formatEpochShort(minCreatedAt)}"
|
||||||
|
else ->
|
||||||
|
"Until ${formatEpochShort(maxCreatedAt!!)}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = onClearDate,
|
||||||
|
label = { Text(label) },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear date filter",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (searchActive) {
|
||||||
|
InputChip(
|
||||||
|
selected = true,
|
||||||
|
onClick = onClearSearch,
|
||||||
|
label = { Text("search: …${filter.searchQuery.takeLast(8)}") },
|
||||||
|
trailingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Clear search",
|
||||||
|
modifier = Modifier.size(16.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
|
||||||
|
// ── Pure helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
internal fun formatEpochShort(epoch: Long): String =
|
||||||
|
java.time.format.DateTimeFormatter.ofPattern("dd MMM")
|
||||||
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
.format(java.time.Instant.ofEpochSecond(epoch))
|
||||||
|
|
||||||
|
internal fun formatTimestamp(createdAt: Long?, rawTime: String): String {
|
||||||
|
val formatter = java.time.format.DateTimeFormatter
|
||||||
|
.ofPattern("dd MMM yyyy, HH:mm")
|
||||||
|
.withZone(java.time.ZoneId.systemDefault())
|
||||||
|
if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
|
||||||
|
return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
|
||||||
|
}
|
||||||
|
// Fallback: only reached for payments not yet written through Room
|
||||||
|
return runCatching {
|
||||||
|
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
|
||||||
|
}.recoverCatching {
|
||||||
|
formatter.format(java.time.OffsetDateTime.parse(rawTime))
|
||||||
|
}.getOrDefault(rawTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun isPendingStatus(status: String): Boolean =
|
||||||
|
status.lowercase() in setOf("pending", "in_flight", "inflight")
|
||||||
|
|
||||||
|
// ── Composable helpers ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun paymentAmountColor(status: String, isOutgoing: Boolean): Color {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
return when {
|
||||||
|
isPendingStatus(status) -> MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
isOutgoing -> MaterialTheme.colorScheme.onSurface
|
||||||
|
else -> semantic.success
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,324 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.Intent
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material.icons.outlined.ContentCopy
|
||||||
|
import androidx.compose.material3.Card
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TopAppBar
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.collectAsState
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.ClipEntry
|
||||||
|
import androidx.compose.ui.platform.LocalClipboard
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.core.net.toUri
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.DetailState
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.HistoryViewModel
|
||||||
|
import com.bitcointxoko.gudariwallet.util.feePpm
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
// ── Detail screen ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun PaymentDetailScreen(
|
||||||
|
payment : PaymentRecord,
|
||||||
|
vm : HistoryViewModel,
|
||||||
|
onBack : () -> Unit
|
||||||
|
) {
|
||||||
|
val detailState by vm.detailState.collectAsState()
|
||||||
|
val fiatCurrency by vm.fiatCurrency.collectAsState()
|
||||||
|
val fiatSatsPerUnit by vm.fiatSatsPerUnit.collectAsState()
|
||||||
|
|
||||||
|
LaunchedEffect(payment.checkingId) { vm.loadDetail(payment.checkingId, record = payment) }
|
||||||
|
DisposableEffect(Unit) { onDispose { vm.clearDetail() } }
|
||||||
|
|
||||||
|
val enriched: PaymentRecord = when (val s = detailState) {
|
||||||
|
is DetailState.Success -> {
|
||||||
|
val detail = s.detail.details
|
||||||
|
when {
|
||||||
|
detail == null -> payment
|
||||||
|
else -> detail.copy(
|
||||||
|
memo = detail.memo?.takeIf { it.isNotBlank() } ?: payment.memo,
|
||||||
|
bolt11 = detail.bolt11?.takeIf { it.isNotBlank() } ?: payment.bolt11
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is DetailState.Partial -> s.record
|
||||||
|
else -> payment
|
||||||
|
}
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(if (payment.isOutgoing) "Outgoing payment" else "Incoming payment") },
|
||||||
|
navigationIcon = {
|
||||||
|
IconButton(onClick = onBack) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { padding ->
|
||||||
|
when (detailState) {
|
||||||
|
is DetailState.Loading -> {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.fillMaxSize().padding(padding),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) { CircularProgressIndicator() }
|
||||||
|
}
|
||||||
|
|
||||||
|
is DetailState.Error -> {
|
||||||
|
Column(Modifier.fillMaxSize().padding(padding)) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer,
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Could not load full details",
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
modifier = Modifier.padding(12.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
PaymentDetailContent(
|
||||||
|
payment = enriched,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
PaymentDetailContent(
|
||||||
|
payment = enriched,
|
||||||
|
fiatCurrency = fiatCurrency,
|
||||||
|
fiatSatsPerUnit = fiatSatsPerUnit,
|
||||||
|
modifier = Modifier.padding(padding)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Detail content ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PaymentDetailContent(
|
||||||
|
payment : PaymentRecord,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
fiatSatsPerUnit : Double?,
|
||||||
|
modifier : Modifier = Modifier
|
||||||
|
) {
|
||||||
|
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||||
|
|
||||||
|
val context = LocalContext.current
|
||||||
|
val clipboard = LocalClipboard.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
val pubkey = payment.pubkey
|
||||||
|
val alias = payment.alias
|
||||||
|
|
||||||
|
val heroFiat: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||||
|
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
val feeFiat: String? = remember(payment.feeSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.feeSat > 0)
|
||||||
|
formatFiatForSats(payment.feeSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(16.dp),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
|
) {
|
||||||
|
|
||||||
|
// ── Hero amount ──────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
Card(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth().padding(24.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
|
else Icons.Default.ArrowDownward,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = amountColor,
|
||||||
|
modifier = Modifier.size(36.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
AmountWithFiatColumn(
|
||||||
|
amountSat = payment.amountSat,
|
||||||
|
isOutgoing = payment.isOutgoing,
|
||||||
|
amountColor = amountColor,
|
||||||
|
fiatLine = heroFiat,
|
||||||
|
style = MaterialTheme.typography.headlineLarge,
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally
|
||||||
|
)
|
||||||
|
if (payment.feeSat > 0) {
|
||||||
|
val ppm = feePpm(
|
||||||
|
amountMsat = kotlin.math.abs(payment.amountMsat),
|
||||||
|
feeMsat = kotlin.math.abs(payment.feeMsat)
|
||||||
|
)
|
||||||
|
val feeLabel = buildString {
|
||||||
|
append("Fee: ${payment.feeSat} sats")
|
||||||
|
if (feeFiat != null) append(" ($feeFiat)")
|
||||||
|
if (ppm != null) append(" · $ppm ppm")
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = feeLabel,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
StatusChip(status = payment.status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic info ───────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Details") {
|
||||||
|
DetailRow("Date", formatTimestamp(payment.createdAt, payment.time))
|
||||||
|
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "—")
|
||||||
|
if (payment.extra?.tag != null) {
|
||||||
|
DetailRow("Type", payment.extra.tag)
|
||||||
|
}
|
||||||
|
if (payment.extra?.comment?.isNotBlank() == true) {
|
||||||
|
DetailRow("Comment", payment.extra.comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Success action (LNURL) ───────────────────────────────────────────
|
||||||
|
payment.extra?.successAction?.let { action ->
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Success Action") {
|
||||||
|
action.message?.let { DetailRow("Message", it) }
|
||||||
|
action.url?.let { DetailRow("URL", it) }
|
||||||
|
action.description?.let { DetailRow("Description", it) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Technical ────────────────────────────────────────────────────────
|
||||||
|
item {
|
||||||
|
DetailSection(title = "Technical") {
|
||||||
|
DetailRow(
|
||||||
|
label = "Payment Hash",
|
||||||
|
value = payment.paymentHash,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("Payment Hash", payment.paymentHash))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!payment.preimage.isNullOrBlank() && payment.preimage != "0".repeat(64)) {
|
||||||
|
DetailRow(
|
||||||
|
label = "Preimage",
|
||||||
|
value = payment.preimage,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
onCopy = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("Preimage", payment.preimage))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (pubkey != null) {
|
||||||
|
val ambossUrl = "https://amboss.space/node/$pubkey"
|
||||||
|
val label = alias ?: "${pubkey.take(8)}…${pubkey.takeLast(8)}"
|
||||||
|
DetailRow(
|
||||||
|
label = "Destination",
|
||||||
|
value = label,
|
||||||
|
valueColor = MaterialTheme.colorScheme.primary,
|
||||||
|
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline,
|
||||||
|
onValueClick = {
|
||||||
|
context.startActivity(Intent(Intent.ACTION_VIEW, ambossUrl.toUri()))
|
||||||
|
},
|
||||||
|
trailingContent = {
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("node_id", pubkey))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(32.dp)
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Outlined.ContentCopy,
|
||||||
|
contentDescription = "Copy node ID",
|
||||||
|
modifier = Modifier.size(14.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (!payment.bolt11.isNullOrBlank()) {
|
||||||
|
DetailRow(
|
||||||
|
label = "BOLT11",
|
||||||
|
value = payment.bolt11,
|
||||||
|
monospace = true,
|
||||||
|
copyable = true,
|
||||||
|
maxLines = 3,
|
||||||
|
onCopy = {
|
||||||
|
scope.launch {
|
||||||
|
clipboard.setClipEntry(
|
||||||
|
ClipEntry(ClipData.newPlainText("BOLT11", payment.bolt11))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.ArrowDownward
|
||||||
|
import androidx.compose.material.icons.filled.ArrowUpward
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import com.bitcointxoko.gudariwallet.api.PaymentRecord
|
||||||
|
import com.bitcointxoko.gudariwallet.ui.theme.semanticColors
|
||||||
|
import com.bitcointxoko.gudariwallet.util.formatFiatForSats
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun PaymentRow(
|
||||||
|
payment : PaymentRecord,
|
||||||
|
fiatCurrency : String?,
|
||||||
|
fiatSatsPerUnit : Double?,
|
||||||
|
onClick : () -> Unit
|
||||||
|
) {
|
||||||
|
val semantic = semanticColors()
|
||||||
|
|
||||||
|
val isPending = isPendingStatus(payment.status)
|
||||||
|
val amountColor = paymentAmountColor(payment.status, payment.isOutgoing)
|
||||||
|
val icon = if (payment.isOutgoing) Icons.Default.ArrowUpward
|
||||||
|
else Icons.Default.ArrowDownward
|
||||||
|
|
||||||
|
val fiatLine: String? = remember(payment.amountSat, fiatSatsPerUnit, fiatCurrency) {
|
||||||
|
if (fiatSatsPerUnit != null && fiatCurrency != null && payment.amountSat > 0)
|
||||||
|
formatFiatForSats(payment.amountSat, fiatSatsPerUnit, fiatCurrency)
|
||||||
|
else null
|
||||||
|
}
|
||||||
|
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = icon,
|
||||||
|
contentDescription = if (payment.isOutgoing) "Sent" else "Received",
|
||||||
|
tint = amountColor,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = payment.memo?.takeIf { it.isNotBlank() } ?: "No memo",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
val formattedTime = remember(payment.createdAt ?: payment.time) {
|
||||||
|
formatTimestamp(payment.createdAt, payment.time)
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = formattedTime,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
if (isPending) {
|
||||||
|
Spacer(Modifier.height(4.dp))
|
||||||
|
Surface(
|
||||||
|
shape = MaterialTheme.shapes.extraSmall,
|
||||||
|
color = semantic.warningContainer,
|
||||||
|
contentColor = semantic.onWarningContainer
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Pending",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(Modifier.width(12.dp))
|
||||||
|
AmountWithFiatColumn(
|
||||||
|
amountSat = payment.amountSat,
|
||||||
|
isOutgoing = payment.isOutgoing,
|
||||||
|
amountColor = amountColor,
|
||||||
|
fiatLine = fiatLine
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.bitcointxoko.gudariwallet.ui.history
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
internal fun SearchInfoSheet(visible: Boolean, onDismiss: () -> Unit) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
if (!visible) return
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(start = 24.dp, end = 24.dp, bottom = 40.dp)
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "What can I search for?",
|
||||||
|
style = MaterialTheme.typography.titleMedium
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Type any part of the following to find a payment:",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(20.dp))
|
||||||
|
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Memo",
|
||||||
|
description = "The message or description attached to a payment. " +
|
||||||
|
"For example, \"coffee\" or \"rent March\"."
|
||||||
|
)
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Invoice",
|
||||||
|
description = "The payment request you scanned or pasted to send " +
|
||||||
|
"a payment. It usually starts with \"lnbc…\". " +
|
||||||
|
"You can paste just the first or last few characters."
|
||||||
|
)
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Hash",
|
||||||
|
description = "A unique code that identifies this payment on the " +
|
||||||
|
"Lightning Network. Useful if someone asks you to " +
|
||||||
|
"confirm a specific transaction."
|
||||||
|
)
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Preimage",
|
||||||
|
description = "A secret code that proves a payment was received. " +
|
||||||
|
"Only available after a payment completes successfully."
|
||||||
|
)
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Node alias",
|
||||||
|
description = "The name of the Lightning node you sent a payment to. " +
|
||||||
|
"For example, \"ACINQ\" or \"Wallet of Satoshi\". " +
|
||||||
|
"Aliases are resolved automatically for outgoing payments."
|
||||||
|
)
|
||||||
|
SearchInfoItem(
|
||||||
|
title = "Node ID (pubkey)",
|
||||||
|
description = "The public key of the destination node. " +
|
||||||
|
"A 66-character hex string — you can paste just the first few characters."
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(Modifier.height(16.dp))
|
||||||
|
Text(
|
||||||
|
text = "You can paste a full value or search with just a few characters — " +
|
||||||
|
"partial matches work too.",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SearchInfoItem(title: String, description: String) {
|
||||||
|
Column(modifier = Modifier.padding(bottom = 16.dp)) {
|
||||||
|
Text(
|
||||||
|
text = title,
|
||||||
|
style = MaterialTheme.typography.labelLarge
|
||||||
|
)
|
||||||
|
Spacer(Modifier.height(2.dp))
|
||||||
|
Text(
|
||||||
|
text = description,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user