Show Monero accounts as a stack of cards you can swipe through
This commit is contained in:
@@ -0,0 +1,115 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// A stack of account cards, Cake Wallet style: the front card is full size and
|
||||||
|
/// interactive, the rest peek from behind (scaled down and offset upwards).
|
||||||
|
/// Drag the front card sideways or down to send it to the back of the deck.
|
||||||
|
struct AccountStack: View {
|
||||||
|
var accounts: [MoneroAccount]
|
||||||
|
/// Converts an account's XMR amount to the chosen fiat currency, if a price
|
||||||
|
/// is available.
|
||||||
|
var fiat: (String) -> String?
|
||||||
|
var onQR: (MoneroAccount) -> Void
|
||||||
|
|
||||||
|
@State private var frontIndex = 0
|
||||||
|
@State private var dragOffset = CGSize.zero
|
||||||
|
|
||||||
|
private let maxDepth = 3
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .top) {
|
||||||
|
ForEach(Array(accounts.enumerated()), id: \.element.id) { index, account in
|
||||||
|
let depth = depth(of: index)
|
||||||
|
accountCard(account, depth: depth)
|
||||||
|
.offset(x: depth == 0 ? dragOffset.width : 0,
|
||||||
|
y: depth == 0 ? dragOffset.height : 0)
|
||||||
|
.gesture(depth == 0 ? frontDrag : nil)
|
||||||
|
.allowsHitTesting(depth == 0)
|
||||||
|
.zIndex(Double(accounts.count - depth))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(height: 190 + CGFloat(min(accounts.count - 1, maxDepth)) * 12)
|
||||||
|
.accessibilityHint("Drag the front card to cycle accounts")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Layout
|
||||||
|
|
||||||
|
/// How far behind the front an account sits (0 = front).
|
||||||
|
private func depth(of index: Int) -> Int {
|
||||||
|
(index - frontIndex + accounts.count) % accounts.count
|
||||||
|
}
|
||||||
|
|
||||||
|
private func accountCard(_ account: MoneroAccount, depth: Int) -> some View {
|
||||||
|
Card {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
HStack {
|
||||||
|
Text(account.title)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
Spacer()
|
||||||
|
Text("\(depth + 1) of \(accounts.count)")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 3) {
|
||||||
|
Text("\(account.balance) XMR")
|
||||||
|
.font(.system(size: 26, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(Theme.text)
|
||||||
|
if let fiat = fiat(account.balance) {
|
||||||
|
Text(fiat)
|
||||||
|
.font(.system(size: 12, weight: .medium))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
if account.unlocked != account.balance {
|
||||||
|
Text("\(account.unlocked) XMR unlocked")
|
||||||
|
.font(.system(size: 11))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text(account.address)
|
||||||
|
.font(.system(size: 11, design: .monospaced))
|
||||||
|
.foregroundStyle(Theme.muted)
|
||||||
|
.lineLimit(1)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
Spacer(minLength: 6)
|
||||||
|
Button {
|
||||||
|
onQR(account)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "qrcode")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(Theme.accent)
|
||||||
|
.frame(width: 34, height: 34)
|
||||||
|
.background(Theme.surfaceAlt)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Cards behind the front one peek from the top, slightly smaller.
|
||||||
|
.scaleEffect(1 - 0.045 * CGFloat(min(depth, maxDepth)))
|
||||||
|
.offset(y: -CGFloat(min(depth, maxDepth)) * 12)
|
||||||
|
.opacity(depth > maxDepth ? 0 : 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Interaction
|
||||||
|
|
||||||
|
private var frontDrag: some Gesture {
|
||||||
|
DragGesture()
|
||||||
|
.onChanged { value in
|
||||||
|
dragOffset = value.translation
|
||||||
|
}
|
||||||
|
.onEnded { value in
|
||||||
|
let threshold: CGFloat = 90
|
||||||
|
let shouldAdvance = abs(value.translation.width) > threshold
|
||||||
|
|| value.translation.height > threshold
|
||||||
|
withAnimation(.spring(response: 0.35, dampingFraction: 0.8)) {
|
||||||
|
dragOffset = .zero
|
||||||
|
if shouldAdvance {
|
||||||
|
frontIndex = (frontIndex + 1) % accounts.count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,6 @@ struct HomeView: View {
|
|||||||
@State private var showDeleteConfirm = false
|
@State private var showDeleteConfirm = false
|
||||||
@State private var selectedTransaction: TransactionRecord?
|
@State private var selectedTransaction: TransactionRecord?
|
||||||
@State private var showSettings = false
|
@State private var showSettings = false
|
||||||
@State private var activeAccount = 0
|
|
||||||
@State private var qrAddress: AddressQR?
|
@State private var qrAddress: AddressQR?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -196,15 +195,11 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
|
if model.chain == .xmr, !model.moneroAccounts.isEmpty {
|
||||||
// Cake Wallet style: one card per account, swiped horizontally.
|
// Cake Wallet style: a deck of account cards, drag the front card
|
||||||
TabView(selection: $activeAccount) {
|
// away to bring the next one up.
|
||||||
ForEach(model.moneroAccounts) { account in
|
AccountStack(accounts: model.moneroAccounts,
|
||||||
moneroAccountCard(account)
|
fiat: { model.fiatValue(of: $0, chain: .xmr) },
|
||||||
.tag(account.index)
|
onQR: { qrAddress = AddressQR(title: $0.title, address: $0.address) })
|
||||||
}
|
|
||||||
}
|
|
||||||
.tabViewStyle(.page(indexDisplayMode: .always))
|
|
||||||
.frame(height: 208)
|
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Balance")
|
Text("Balance")
|
||||||
@@ -302,51 +297,6 @@ struct HomeView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func moneroAccountCard(_ account: MoneroAccount) -> some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
HStack {
|
|
||||||
Text(account.title)
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
.foregroundStyle(Theme.muted)
|
|
||||||
Spacer()
|
|
||||||
Text("Account \(account.index + 1) of \(model.moneroAccounts.count)")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Theme.muted)
|
|
||||||
}
|
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 3) {
|
|
||||||
Text("\(account.balance) XMR")
|
|
||||||
.font(.system(size: 26, weight: .bold, design: .rounded))
|
|
||||||
.foregroundStyle(Theme.text)
|
|
||||||
if let fiat = model.fiatValue(of: account.balance, chain: .xmr) {
|
|
||||||
Text(fiat)
|
|
||||||
.font(.system(size: 12, weight: .medium))
|
|
||||||
.foregroundStyle(Theme.muted)
|
|
||||||
}
|
|
||||||
if account.unlocked != account.balance {
|
|
||||||
Text("\(account.unlocked) XMR unlocked")
|
|
||||||
.font(.system(size: 11))
|
|
||||||
.foregroundStyle(Theme.muted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HStack(spacing: 8) {
|
|
||||||
AddressRow(address: account.address)
|
|
||||||
Button {
|
|
||||||
qrAddress = AddressQR(title: account.title, address: account.address)
|
|
||||||
} label: {
|
|
||||||
Image(systemName: "qrcode")
|
|
||||||
.font(.system(size: 13, weight: .semibold))
|
|
||||||
.foregroundStyle(Theme.accent)
|
|
||||||
.frame(width: 36, height: 36)
|
|
||||||
.background(Theme.surfaceAlt)
|
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.padding(.bottom, 18)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var moneroUnavailableCard: some View {
|
private var moneroUnavailableCard: some View {
|
||||||
VStack(spacing: 14) {
|
VStack(spacing: 14) {
|
||||||
ChainPicker(selection: Binding(get: { model.chain },
|
ChainPicker(selection: Binding(get: { model.chain },
|
||||||
|
|||||||
Reference in New Issue
Block a user