montana/macOS/MontanaPresence/PhoneView.swift

550 lines
18 KiB
Swift
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
/// 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)
}