550 lines
18 KiB
Swift
550 lines
18 KiB
Swift
import SwiftUI
|
||
|
||
/// Montana Phone Service
|
||
/// Виртуальные номера + привязка реальных номеров
|
||
struct PhoneView: View {
|
||
@EnvironmentObject var engine: PresenceEngine
|
||
@State private var selectedTab = 0
|
||
@State private var showSidebar = false
|
||
|
||
private let gold = Color(red: 0.85, green: 0.68, blue: 0.25)
|
||
|
||
var body: some View {
|
||
ZStack(alignment: .leading) {
|
||
VStack(spacing: 0) {
|
||
// ── BURGER MENU BUTTON ──
|
||
HStack {
|
||
Button(action: { withAnimation(.easeInOut(duration: 0.3)) { showSidebar = true } }) {
|
||
Image(systemName: "line.3.horizontal")
|
||
.font(.system(size: 18, weight: .medium))
|
||
.foregroundColor(gold)
|
||
.padding(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
Spacer()
|
||
}
|
||
.padding(.horizontal, 12)
|
||
.padding(.top, 8)
|
||
|
||
// Header
|
||
header
|
||
|
||
Divider()
|
||
|
||
// Tab selector
|
||
Picker("", selection: $selectedTab) {
|
||
Text("Виртуальные").tag(0)
|
||
Text("Реальные").tag(1)
|
||
}
|
||
.pickerStyle(.segmented)
|
||
.padding()
|
||
|
||
// Content
|
||
TabView(selection: $selectedTab) {
|
||
VirtualPhoneView()
|
||
.environmentObject(engine)
|
||
.tag(0)
|
||
|
||
RealPhoneView()
|
||
.environmentObject(engine)
|
||
.tag(1)
|
||
}
|
||
.tabViewStyle(.automatic)
|
||
}
|
||
.background(Color(NSColor.windowBackgroundColor))
|
||
|
||
// Sidebar overlay
|
||
if showSidebar {
|
||
Color.black.opacity(0.3)
|
||
.ignoresSafeArea()
|
||
.onTapGesture {
|
||
withAnimation(.easeInOut(duration: 0.3)) {
|
||
showSidebar = false
|
||
}
|
||
}
|
||
|
||
SharedSidebar(isVisible: $showSidebar)
|
||
.transition(.move(edge: .leading))
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Header
|
||
|
||
private var header: some View {
|
||
HStack(spacing: 12) {
|
||
// Phone icon
|
||
Circle()
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [
|
||
Color(red: 0.0, green: 0.83, blue: 1.0), // #00d4ff cyan
|
||
Color(red: 0.48, green: 0.18, blue: 1.0) // #7b2fff purple
|
||
],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: 40, height: 40)
|
||
.overlay(
|
||
Text("📞")
|
||
.font(.system(size: 20))
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Телефонные номера")
|
||
.font(.headline)
|
||
Text("Виртуальные и реальные номера Montana")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
|
||
// MARK: - Virtual Phone View
|
||
|
||
struct VirtualPhoneView: View {
|
||
@EnvironmentObject var engine: PresenceEngine
|
||
@State private var currentPrice = 1
|
||
@State private var totalSold = 0
|
||
@State private var isLoading = false
|
||
@State private var statusMessage = ""
|
||
@State private var ownedNumbers: [OwnedNumber] = []
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 24) {
|
||
// Registration card
|
||
registrationCard
|
||
|
||
// Owned numbers
|
||
if !ownedNumbers.isEmpty {
|
||
ownedNumbersSection
|
||
}
|
||
|
||
// Info section
|
||
infoSection
|
||
}
|
||
.padding()
|
||
}
|
||
.onAppear {
|
||
loadCurrentPrice()
|
||
loadOwnedNumbers()
|
||
}
|
||
}
|
||
|
||
private var registrationCard: some View {
|
||
VStack(spacing: 16) {
|
||
Text("Купить виртуальный номер")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
Text("+montana-\(String(format: "%06d", currentPrice))")
|
||
.font(.system(size: 24, design: .monospaced))
|
||
.fontWeight(.bold)
|
||
.foregroundColor(Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
|
||
Text("Цена: \(currentPrice) Ɉ")
|
||
.font(.title2)
|
||
.fontWeight(.semibold)
|
||
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(statusMessage.hasPrefix("✓") ? .green : .red)
|
||
}
|
||
|
||
Button(action: registerNumber) {
|
||
HStack {
|
||
if isLoading {
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
.padding(.trailing, 4)
|
||
}
|
||
Text("Купить за \(currentPrice) Ɉ")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(isLoading ? Color.gray : Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
.foregroundColor(.white)
|
||
.cornerRadius(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(isLoading)
|
||
}
|
||
.padding()
|
||
.background(Color.blue.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
private var ownedNumbersSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Мои номера")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
ForEach(ownedNumbers) { number in
|
||
HStack {
|
||
Text(number.formatted)
|
||
.font(.system(.body, design: .monospaced))
|
||
|
||
Spacer()
|
||
|
||
Text("\(number.pricePaid) Ɉ")
|
||
.foregroundColor(.secondary)
|
||
.font(.caption)
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var infoSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("💡 Виртуальные номера")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
InfoRow(icon: "1️⃣", text: "1-й номер: 1 Ɉ")
|
||
InfoRow(icon: "🔢", text: "N-й номер: N Ɉ")
|
||
InfoRow(icon: "📞", text: "Формат: +montana-000042")
|
||
InfoRow(icon: "💰", text: "Звонки: 1 Ɉ/сек для владельцев")
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color.green.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
private func loadCurrentPrice() {
|
||
Task { @MainActor in
|
||
do {
|
||
let (price, sold) = try await engine.api.fetchAuctionPrice(serviceType: "phone_number")
|
||
currentPrice = price
|
||
totalSold = sold
|
||
} catch {
|
||
currentPrice = 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadOwnedNumbers() {
|
||
guard let addr = engine.address else { return }
|
||
Task { @MainActor in
|
||
do {
|
||
let events = try await engine.api.fetchMyEvents(address: addr)
|
||
ownedNumbers = events.compactMap { event in
|
||
guard let type = event["event_type"] as? String,
|
||
type == "phone_register",
|
||
let metadata = event["metadata"] as? [String: Any],
|
||
let phoneStr = metadata["phone_number"] as? String,
|
||
let amount = event["amount"] as? Int else { return nil }
|
||
let numStr = phoneStr.replacingOccurrences(of: "+montana-", with: "")
|
||
let num = Int(numStr) ?? 0
|
||
return OwnedNumber(number: num, formatted: phoneStr, pricePaid: amount)
|
||
}
|
||
} catch {
|
||
ownedNumbers = []
|
||
}
|
||
}
|
||
}
|
||
|
||
private func registerNumber() {
|
||
guard let ownerAddress = engine.address else {
|
||
statusMessage = "❌ Нет адреса кошелька"
|
||
return
|
||
}
|
||
isLoading = true
|
||
statusMessage = ""
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let result = try await engine.api.registerPhone(
|
||
number: currentPrice,
|
||
ownerAddress: ownerAddress,
|
||
amount: currentPrice
|
||
)
|
||
let phoneNumber = result["phone_number"] as? String ?? "+montana-\(String(format: "%06d", currentPrice))"
|
||
let pricePaid = result["price_paid"] as? Int ?? currentPrice
|
||
statusMessage = "✓ Номер \(phoneNumber) зарегистрирован!"
|
||
ownedNumbers.append(OwnedNumber(number: currentPrice, formatted: phoneNumber, pricePaid: pricePaid))
|
||
await engine.syncBalance()
|
||
loadCurrentPrice()
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Real Phone View
|
||
|
||
struct RealPhoneView: View {
|
||
@EnvironmentObject var engine: PresenceEngine
|
||
@State private var phoneInput = ""
|
||
@State private var codeInput = ""
|
||
@State private var isVerificationSent = false
|
||
@State private var isLoading = false
|
||
@State private var statusMessage = ""
|
||
@State private var boundPhones: [BoundPhone] = []
|
||
|
||
var body: some View {
|
||
ScrollView {
|
||
VStack(spacing: 24) {
|
||
// Binding card
|
||
bindingCard
|
||
|
||
// Bound phones
|
||
if !boundPhones.isEmpty {
|
||
boundPhonesSection
|
||
}
|
||
|
||
// Info section
|
||
infoSection
|
||
}
|
||
.padding()
|
||
}
|
||
.onAppear {
|
||
loadBoundPhones()
|
||
}
|
||
}
|
||
|
||
private var bindingCard: some View {
|
||
VStack(spacing: 16) {
|
||
Text("Привязать реальный номер")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
if !isVerificationSent {
|
||
// Step 1: Enter phone number
|
||
VStack(spacing: 12) {
|
||
TextField("+7-921-123-4567", text: $phoneInput)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 16, design: .monospaced))
|
||
.padding(10)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
.disabled(isLoading)
|
||
|
||
Button(action: requestVerification) {
|
||
HStack {
|
||
if isLoading {
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
.padding(.trailing, 4)
|
||
}
|
||
Text("Отправить код")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(phoneInput.isEmpty || isLoading ? Color.gray : Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
.foregroundColor(.white)
|
||
.cornerRadius(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(phoneInput.isEmpty || isLoading)
|
||
}
|
||
} else {
|
||
// Step 2: Enter verification code
|
||
VStack(spacing: 12) {
|
||
Text("Код отправлен на \(phoneInput)")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
|
||
TextField("000000", text: $codeInput)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 20, design: .monospaced))
|
||
.padding(10)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
.disabled(isLoading)
|
||
|
||
Button(action: verifyCode) {
|
||
HStack {
|
||
if isLoading {
|
||
ProgressView()
|
||
.scaleEffect(0.8)
|
||
.padding(.trailing, 4)
|
||
}
|
||
Text("Подтвердить")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 10)
|
||
.background(codeInput.count != 6 || isLoading ? Color.gray : Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
.foregroundColor(.white)
|
||
.cornerRadius(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(codeInput.count != 6 || isLoading)
|
||
|
||
Button("Отменить") {
|
||
isVerificationSent = false
|
||
codeInput = ""
|
||
statusMessage = ""
|
||
}
|
||
.buttonStyle(.plain)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
|
||
if !statusMessage.isEmpty {
|
||
Text(statusMessage)
|
||
.font(.caption)
|
||
.foregroundColor(statusMessage.hasPrefix("✓") ? .green : .red)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color.purple.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
private var boundPhonesSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("Привязанные номера")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
ForEach(boundPhones) { phone in
|
||
HStack {
|
||
Text(phone.number)
|
||
.font(.system(.body, design: .monospaced))
|
||
|
||
Spacer()
|
||
|
||
Text("✓ Проверен")
|
||
.foregroundColor(.green)
|
||
.font(.caption)
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
|
||
private var infoSection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("💡 Реальные номера")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
VStack(alignment: .leading, spacing: 8) {
|
||
InfoRow(icon: "📱", text: "Привяжи свой реальный номер")
|
||
InfoRow(icon: "💬", text: "SMS верификация")
|
||
InfoRow(icon: "🔐", text: "Номер = кошелек Montana")
|
||
InfoRow(icon: "📞", text: "Звонки по 1 Ɉ/сек")
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color.purple.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
private func loadBoundPhones() {
|
||
guard let addr = engine.address else { return }
|
||
Task { @MainActor in
|
||
do {
|
||
let phones = try await engine.api.fetchBoundPhones(address: addr)
|
||
boundPhones = phones.compactMap { phone in
|
||
guard let number = phone["phone"] as? String else { return nil }
|
||
return BoundPhone(number: number)
|
||
}
|
||
} catch {
|
||
boundPhones = []
|
||
}
|
||
}
|
||
}
|
||
|
||
private func requestVerification() {
|
||
guard let addr = engine.address else {
|
||
statusMessage = "❌ Нет адреса кошелька"
|
||
return
|
||
}
|
||
isLoading = true
|
||
statusMessage = ""
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let _ = try await engine.api.requestPhoneBind(phone: phoneInput, montanaAddress: addr)
|
||
isVerificationSent = true
|
||
statusMessage = "✓ Код отправлен на \(phoneInput)"
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
|
||
private func verifyCode() {
|
||
guard let addr = engine.address else { return }
|
||
isLoading = true
|
||
statusMessage = ""
|
||
|
||
Task { @MainActor in
|
||
do {
|
||
let _ = try await engine.api.verifyPhoneBind(
|
||
phone: phoneInput,
|
||
montanaAddress: addr,
|
||
code: codeInput
|
||
)
|
||
statusMessage = "✓ Номер \(phoneInput) успешно привязан!"
|
||
boundPhones.append(BoundPhone(number: phoneInput))
|
||
phoneInput = ""
|
||
codeInput = ""
|
||
isVerificationSent = false
|
||
} catch {
|
||
statusMessage = "❌ \(error.localizedDescription)"
|
||
}
|
||
isLoading = false
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Models
|
||
|
||
struct OwnedNumber: Identifiable {
|
||
let id = UUID()
|
||
let number: Int
|
||
let formatted: String
|
||
let pricePaid: Int
|
||
}
|
||
|
||
struct BoundPhone: Identifiable {
|
||
let id = UUID()
|
||
let number: String
|
||
}
|
||
|
||
private struct InfoRow: View {
|
||
let icon: String
|
||
let text: String
|
||
|
||
var body: some View {
|
||
HStack(spacing: 8) {
|
||
Text(icon)
|
||
.font(.body)
|
||
Text(text)
|
||
.font(.callout)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Preview
|
||
|
||
#Preview {
|
||
PhoneView()
|
||
.environmentObject(PresenceEngine.shared)
|
||
.frame(width: 600, height: 500)
|
||
}
|