montana/macOS/MontanaPresence/PhoneView.swift

550 lines
18 KiB
Swift
Raw Normal View History

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