diff --git a/apps/ios/Koins/Views/AccountStack.swift b/apps/ios/Koins/Views/AccountStack.swift new file mode 100644 index 0000000..afd0982 --- /dev/null +++ b/apps/ios/Koins/Views/AccountStack.swift @@ -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 + } + } + } + } +} \ No newline at end of file diff --git a/apps/ios/Koins/Views/RootView.swift b/apps/ios/Koins/Views/RootView.swift index bb44c7e..3ae02be 100644 --- a/apps/ios/Koins/Views/RootView.swift +++ b/apps/ios/Koins/Views/RootView.swift @@ -94,7 +94,6 @@ struct HomeView: View { @State private var showDeleteConfirm = false @State private var selectedTransaction: TransactionRecord? @State private var showSettings = false - @State private var activeAccount = 0 @State private var qrAddress: AddressQR? var body: some View { @@ -196,15 +195,11 @@ struct HomeView: View { } if model.chain == .xmr, !model.moneroAccounts.isEmpty { - // Cake Wallet style: one card per account, swiped horizontally. - TabView(selection: $activeAccount) { - ForEach(model.moneroAccounts) { account in - moneroAccountCard(account) - .tag(account.index) - } - } - .tabViewStyle(.page(indexDisplayMode: .always)) - .frame(height: 208) + // Cake Wallet style: a deck of account cards, drag the front card + // away to bring the next one up. + AccountStack(accounts: model.moneroAccounts, + fiat: { model.fiatValue(of: $0, chain: .xmr) }, + onQR: { qrAddress = AddressQR(title: $0.title, address: $0.address) }) } else { VStack(alignment: .leading, spacing: 4) { 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 { VStack(spacing: 14) { ChainPicker(selection: Binding(get: { model.chain },