montana/macOS/MontanaPresence/SendView.swift

651 lines
24 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import SwiftUI
enum SendMethod: String, CaseIterable {
case montanaID = "montanaID"
case nickname = "nickname"
case fullAddress = "fullAddress"
var label: String {
switch self {
case .montanaID: return "Ɉ-ID"
case .nickname: return "@ Ник"
case .fullAddress: return "mt... Адрес"
}
}
var placeholder: String {
switch self {
case .montanaID: return "1"
case .nickname: return "@junomoneta"
case .fullAddress: return "mt..."
}
}
var icon: String {
switch self {
case .montanaID: return "person.text.rectangle"
case .nickname: return "at"
case .fullAddress: return "key.horizontal"
}
}
}
// Состояния экрана отправки
enum SendStep {
case form // ввод данных
case preConfirm // подтверждение перед отправкой
case result // результат
}
struct SendView: View {
@EnvironmentObject var engine: PresenceEngine
var onShowHistory: (() -> Void)? = nil
@State private var step: SendStep = .form
@State private var sendMethod: SendMethod = .montanaID
@State private var recipient = ""
@State private var amount = ""
@State private var statusText = ""
@State private var statusColor: Color = .secondary
@State private var resolvedAddress = ""
@State private var resolvedAlias = ""
@State private var isSending = false
@State private var sentAmount = 0
@State private var sentRecipient = ""
@State private var sentTimestamp = ""
@FocusState private var recipientFocused: Bool
@Environment(\.dismiss) private var dismiss
private let cyan = Color(red: 0, green: 0.83, blue: 1)
private let purple = Color(red: 0.48, green: 0.18, blue: 1)
private let cardBg = Color(red: 0.09, green: 0.09, blue: 0.12)
private let gold = Color(red: 0.85, green: 0.68, blue: 0.25)
var body: some View {
Group {
switch step {
case .form:
sendFormView
case .preConfirm:
preConfirmView
case .result:
resultView
}
}
.frame(width: 320, height: 440)
}
//
// STEP 2: Подтверждение перед отправкой
//
private var preConfirmView: some View {
VStack(spacing: 0) {
// Header
HStack {
Image(systemName: "exclamationmark.shield.fill")
.foregroundColor(gold)
Text("Подтверждение")
.font(.system(size: 16, weight: .bold))
Spacer()
Button(action: { step = .form }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 16)
Spacer()
Text("Вы отправляете")
.font(.system(size: 12))
.foregroundColor(.secondary)
.padding(.bottom, 6)
Text(formatAmount(Int(amount) ?? 0))
.font(.system(size: 28, weight: .bold, design: .monospaced))
.foregroundColor(cyan)
.padding(.bottom, 16)
VStack(spacing: 10) {
confirmRow(label: "Получатель", value: resolvedAlias.isEmpty ? displayAddr(resolvedAddress) : resolvedAlias)
confirmRow(label: "Адрес", value: displayAddr(resolvedAddress))
}
.padding(14)
.background(cardBg)
.cornerRadius(10)
.padding(.horizontal, 20)
Spacer()
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 11))
.foregroundColor(statusColor)
.padding(.horizontal, 16)
.padding(.bottom, 6)
}
// Подтвердить
Button(action: { executeTransfer() }) {
HStack(spacing: 8) {
if isSending {
ProgressView()
.controlSize(.small)
} else {
Image(systemName: "checkmark.shield.fill")
}
Text("Подтвердить")
.font(.system(size: 14, weight: .bold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(gold)
.controlSize(.large)
.disabled(isSending)
.padding(.horizontal, 16)
.padding(.bottom, 8)
// Назад
Button(action: { step = .form }) {
Text("Назад")
.font(.system(size: 12))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.padding(.bottom, 14)
}
}
//
// STEP 3: Результат Готово + История
//
private var resultView: some View {
VStack(spacing: 0) {
Spacer()
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 48))
.foregroundColor(.green)
.padding(.bottom, 16)
Text("Отправлено")
.font(.system(size: 18, weight: .bold))
.foregroundColor(.green)
.padding(.bottom, 20)
VStack(spacing: 12) {
confirmRow(label: "Сумма", value: formatAmount(sentAmount))
confirmRow(label: "Получатель", value: sentRecipient)
confirmRow(label: "Время", value: sentTimestamp)
}
.padding(16)
.background(cardBg)
.cornerRadius(10)
.padding(.horizontal, 24)
Spacer()
// История
Button(action: {
dismiss()
onShowHistory?()
}) {
HStack(spacing: 6) {
Image(systemName: "clock.arrow.circlepath")
Text("Посмотреть историю")
.font(.system(size: 13, weight: .medium))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(gold)
.controlSize(.large)
.padding(.horizontal, 16)
.padding(.bottom, 8)
// Готово
Button(action: { dismiss() }) {
Text("Готово")
.font(.system(size: 13, weight: .medium))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.padding(.bottom, 14)
}
}
private func confirmRow(label: String, value: String) -> some View {
HStack {
Text(label)
.font(.system(size: 11))
.foregroundColor(.secondary)
Spacer()
Text(value)
.font(.system(size: 13, weight: .bold, design: .monospaced))
.foregroundColor(.white)
}
}
//
// STEP 1: Форма ввода
//
private var sendFormView: some View {
VStack(spacing: 0) {
// Header
HStack {
Image(systemName: "paperplane.fill")
.foregroundColor(cyan)
Text("Ɉ Отправить")
.font(.system(size: 16, weight: .bold))
Spacer()
Button(action: { dismiss() }) {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 10)
// Balance card
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Доступно")
.font(.system(size: 10))
.foregroundColor(.secondary)
Text("\(formatAmount(engine.availableBalance))")
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundColor(cyan)
}
Spacer()
}
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(cardBg)
.cornerRadius(8)
.padding(.horizontal, 16)
.padding(.bottom, 12)
// Send method picker
VStack(alignment: .leading, spacing: 6) {
Text("Как отправить")
.font(.system(size: 10, weight: .medium))
.foregroundColor(.secondary)
.padding(.horizontal, 16)
HStack(spacing: 4) {
ForEach(SendMethod.allCases, id: \.self) { method in
methodButton(method)
}
}
.padding(.horizontal, 16)
}
.padding(.bottom, 10)
// Recipient input
VStack(spacing: 6) {
HStack(spacing: 6) {
Image(systemName: sendMethod.icon)
.foregroundColor(cyan.opacity(0.6))
.font(.system(size: 12))
.frame(width: 20)
TextField(sendMethod.placeholder, text: $recipient)
.font(.system(size: 14, weight: .medium, design: .monospaced))
.textFieldStyle(.plain)
.focused($recipientFocused)
.onSubmit { resolveRecipient() }
.onChange(of: recipient) { _ in
resolvedAddress = ""
resolvedAlias = ""
statusText = ""
}
.onChange(of: recipientFocused) { focused in
if !focused && !recipient.isEmpty && resolvedAddress.isEmpty {
resolveRecipient()
}
}
Button(action: {
if let str = NSPasteboard.general.string(forType: .string) {
recipient = String(str.trimmingCharacters(in: .whitespacesAndNewlines).prefix(100))
resolveRecipient()
}
}) {
Image(systemName: "doc.on.clipboard")
.font(.system(size: 11))
.foregroundColor(.secondary)
}
.buttonStyle(.plain)
.help("Вставить")
}
.padding(10)
.background(cardBg)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(recipientFocused ? cyan.opacity(0.5) : Color.secondary.opacity(0.2), lineWidth: 1)
)
.padding(.horizontal, 16)
if !resolvedAlias.isEmpty {
HStack(spacing: 4) {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.green)
.font(.system(size: 10))
Text(resolvedAlias)
.font(.system(size: 11, weight: .medium))
.foregroundColor(.green)
Spacer()
}
.padding(.horizontal, 20)
}
}
.padding(.bottom, 10)
// Amount input
VStack(spacing: 8) {
HStack(spacing: 6) {
Text("Ɉ")
.font(.system(size: 18, weight: .bold, design: .monospaced))
.foregroundColor(gold.opacity(0.5))
TextField("0", text: $amount)
.font(.system(size: 22, weight: .bold, design: .monospaced))
.textFieldStyle(.plain)
.frame(maxWidth: .infinity)
}
.padding(10)
.background(cardBg)
.cornerRadius(8)
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(Color.secondary.opacity(0.2), lineWidth: 1)
)
.padding(.horizontal, 16)
// Quick amount buttons
HStack(spacing: 6) {
quickBtn(100)
quickBtn(1000)
quickBtn(10000)
Button(action: { amount = "\(engine.availableBalance)" }) {
Text("MAX")
.font(.system(size: 10, weight: .bold))
.foregroundColor(cyan)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(cyan.opacity(0.1))
.cornerRadius(6)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
}
// Warnings
if let amt = Int(amount), amt > engine.availableBalance, engine.availableBalance > 0 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9))
Text("Недостаточно средств")
}
.font(.system(size: 10))
.foregroundColor(.orange)
.padding(.top, 6)
}
if engine.availableBalance == 0 {
HStack(spacing: 4) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 9))
Text("Нет доступных средств")
}
.font(.system(size: 10))
.foregroundColor(.orange)
.padding(.top, 6)
}
Spacer()
// Status
if !statusText.isEmpty {
Text(statusText)
.font(.system(size: 11))
.foregroundColor(statusColor)
.lineLimit(2)
.multilineTextAlignment(.center)
.padding(.horizontal, 16)
.padding(.bottom, 6)
}
// Send button переход к подтверждению
Button(action: { prepareConfirmation() }) {
HStack(spacing: 8) {
Image(systemName: "paperplane.fill")
Text("Отправить")
.font(.system(size: 14, weight: .bold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 4)
}
.buttonStyle(.borderedProminent)
.tint(canSend ? cyan : Color.secondary.opacity(0.3))
.controlSize(.large)
.disabled(!canSend)
.padding(.horizontal, 16)
.padding(.bottom, 14)
}
}
// MARK: - Subviews
private func methodButton(_ method: SendMethod) -> some View {
let isSelected = sendMethod == method
return Button(action: {
sendMethod = method
recipient = ""
resolvedAddress = ""
resolvedAlias = ""
statusText = ""
}) {
VStack(spacing: 3) {
Image(systemName: method.icon)
.font(.system(size: 11))
Text(method.label)
.font(.system(size: 8, weight: .medium))
.lineLimit(1)
}
.frame(maxWidth: .infinity)
.padding(.vertical, 6)
.background(isSelected ? cyan.opacity(0.15) : cardBg)
.foregroundColor(isSelected ? cyan : .secondary)
.cornerRadius(6)
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(isSelected ? cyan.opacity(0.4) : Color.clear, lineWidth: 1)
)
}
.buttonStyle(.plain)
}
private func quickBtn(_ value: Int) -> some View {
Button(action: { amount = "\(value)" }) {
Text("\(value)")
.font(.system(size: 10, weight: .medium, design: .monospaced))
.foregroundColor(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(cardBg)
.cornerRadius(6)
}
.buttonStyle(.plain)
}
private func formatAmount(_ amount: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.groupingSeparator = " "
let formatted = formatter.string(from: NSNumber(value: amount)) ?? "\(amount)"
return "\(formatted) Ɉ"
}
// MARK: - Helpers
private func displayAddr(_ addr: String) -> String {
guard addr.count > 10 else { return addr }
return String(addr.prefix(6)) + "..." + String(addr.suffix(4))
}
// MARK: - Logic
private var canSend: Bool {
guard let amt = Int(amount), amt > 0, amt <= engine.availableBalance else { return false }
return !recipient.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty && !isSending
}
private func extractLookupID(from input: String) -> String? {
switch sendMethod {
case .montanaID:
// Символ Ɉ зашит вводится только номер цифрами
if input.hasPrefix("Ɉ-") { return String(input.dropFirst(2)) }
else if input.hasPrefix("J-") || input.hasPrefix("j-") { return String(input.dropFirst(2)) }
let digits = input.filter { $0.isNumber }
return digits.isEmpty ? nil : digits
case .nickname:
let nick = input.hasPrefix("@") ? String(input.dropFirst()) : input
return nick.isEmpty ? nil : nick
case .fullAddress:
return nil
}
}
private func resolveRecipient() {
let input = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
guard !input.isEmpty else { return }
if sendMethod == .fullAddress {
if input.hasPrefix("mt") && input.count == 42 {
resolvedAddress = input
resolvedAlias = String(input.prefix(8)) + "..." + String(input.suffix(4))
} else {
statusText = "Адрес должен начинаться с mt и содержать 42 символа"
statusColor = .orange
}
return
}
guard let lookupID = extractLookupID(from: input) else { return }
statusText = "Ищу..."
statusColor = .secondary
Task { @MainActor in
do {
let (addr, alias) = try await engine.api.lookupWallet(identifier: lookupID)
if addr == (engine.address ?? "") {
resolvedAddress = ""
resolvedAlias = ""
statusText = "Нельзя отправить себе"
statusColor = .orange
return
}
resolvedAddress = addr
resolvedAlias = alias
statusText = ""
} catch {
statusText = "Адрес не найден"
statusColor = .red
}
}
}
/// Шаг 1 2: Резолвим адрес и показываем экран подтверждения
private func prepareConfirmation() {
guard !isSending else { return }
guard let _ = Int(amount), canSend else { return }
// Если адрес ещё не резолвлен резолвим
if resolvedAddress.isEmpty {
let input = recipient.trimmingCharacters(in: .whitespacesAndNewlines)
guard !input.isEmpty else {
statusText = "Введите получателя"
statusColor = .orange
return
}
if sendMethod == .fullAddress && input.hasPrefix("mt") && input.count == 42 {
resolvedAddress = input
resolvedAlias = String(input.prefix(8)) + "..." + String(input.suffix(4))
step = .preConfirm
} else if let lookupID = extractLookupID(from: input) {
isSending = true
statusText = "Ищу..."
statusColor = .secondary
Task { @MainActor in
defer { isSending = false }
do {
let (addr, alias) = try await engine.api.lookupWallet(identifier: lookupID)
guard addr != (engine.address ?? "") else {
statusText = "Нельзя отправить себе"
statusColor = .orange
return
}
resolvedAddress = addr
resolvedAlias = alias
statusText = ""
step = .preConfirm
} catch {
statusText = "Адрес не найден"
statusColor = .red
}
}
}
} else {
guard resolvedAddress != (engine.address ?? "") else {
statusText = "Нельзя отправить себе"
statusColor = .orange
return
}
step = .preConfirm
}
}
/// Шаг 2 3: Реальная отправка после подтверждения
private func executeTransfer() {
guard !isSending else { return }
guard let amt = Int(amount), amt > 0 else { return }
guard !resolvedAddress.isEmpty else { return }
isSending = true
statusText = ""
Task { @MainActor in
defer { isSending = false }
do {
try await engine.api.transfer(
from: engine.address ?? "",
to: resolvedAddress,
amount: amt
)
await engine.syncBalance()
let df = DateFormatter()
df.dateFormat = "dd.MM.yyyy HH:mm:ss"
sentAmount = amt
sentRecipient = resolvedAlias.isEmpty ? displayAddr(resolvedAddress) : resolvedAlias
sentTimestamp = df.string(from: Date())
step = .result
} catch {
statusText = "Ошибка отправки. Попробуйте позже"
statusColor = .red
}
}
}
}