359 lines
12 KiB
Swift
359 lines
12 KiB
Swift
import SwiftUI
|
||
|
||
/// Montana Calls — Audio & Video
|
||
/// Управление звонками (1 Ɉ/сек для владельцев номеров)
|
||
struct CallsView: View {
|
||
@EnvironmentObject var engine: PresenceEngine
|
||
@State private var recipientInput = ""
|
||
@State private var callType: CallType = .audio
|
||
@State private var callHistory: [CallRecord] = []
|
||
@State private var showSidebar = false
|
||
@State private var audioPricing = 1
|
||
@State private var videoPricing = 1
|
||
@State private var showCallAlert = false
|
||
|
||
private let gold = Color(red: 0.85, green: 0.68, blue: 0.25)
|
||
|
||
enum CallType: String, CaseIterable {
|
||
case audio = "Аудио"
|
||
case video = "Видео"
|
||
}
|
||
|
||
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()
|
||
|
||
// Main content
|
||
ScrollView {
|
||
VStack(spacing: 24) {
|
||
// Call interface
|
||
callCard
|
||
|
||
// Pricing info
|
||
pricingCard
|
||
|
||
// Call history
|
||
if !callHistory.isEmpty {
|
||
historySection
|
||
}
|
||
}
|
||
.padding()
|
||
}
|
||
}
|
||
.background(Color(NSColor.windowBackgroundColor))
|
||
.onAppear {
|
||
loadCallHistory()
|
||
loadPricing()
|
||
}
|
||
.alert("Звонки Montana", isPresented: $showCallAlert) {
|
||
Button("OK") { }
|
||
} message: {
|
||
Text("VoIP звонки скоро будут доступны. Следите за обновлениями!")
|
||
}
|
||
|
||
// 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) {
|
||
// Call 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(
|
||
Image(systemName: "phone.fill")
|
||
.font(.system(size: 18))
|
||
.foregroundColor(.white)
|
||
)
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text("Звонки Montana")
|
||
.font(.headline)
|
||
Text("Аудио и видео звонки")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
}
|
||
.padding()
|
||
}
|
||
|
||
// MARK: - Call Card
|
||
|
||
private var callCard: some View {
|
||
VStack(spacing: 16) {
|
||
Text("Совершить звонок")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
// Recipient input
|
||
HStack(spacing: 8) {
|
||
TextField("alice@efir.org", text: $recipientInput)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 16))
|
||
.padding(10)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
|
||
Text("или")
|
||
.foregroundColor(.secondary)
|
||
.font(.caption)
|
||
|
||
TextField("+montana-000042", text: $recipientInput)
|
||
.textFieldStyle(.plain)
|
||
.font(.system(size: 16, design: .monospaced))
|
||
.padding(10)
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
// Call type picker
|
||
Picker("Тип звонка", selection: $callType) {
|
||
ForEach(CallType.allCases, id: \.self) { type in
|
||
HStack {
|
||
Image(systemName: type == .audio ? "mic.fill" : "video.fill")
|
||
Text(type.rawValue)
|
||
}
|
||
.tag(type)
|
||
}
|
||
}
|
||
.pickerStyle(.segmented)
|
||
|
||
// Call button
|
||
Button(action: initiateCall) {
|
||
HStack(spacing: 8) {
|
||
Image(systemName: callType == .audio ? "phone.fill" : "video.fill")
|
||
Text("Позвонить")
|
||
.fontWeight(.semibold)
|
||
}
|
||
.frame(maxWidth: .infinity)
|
||
.padding(.vertical, 12)
|
||
.background(recipientInput.isEmpty ? Color.gray : Color.green)
|
||
.foregroundColor(.white)
|
||
.cornerRadius(8)
|
||
}
|
||
.buttonStyle(.plain)
|
||
.disabled(recipientInput.isEmpty)
|
||
}
|
||
.padding()
|
||
.background(Color.blue.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
// MARK: - Pricing Card
|
||
|
||
private var pricingCard: some View {
|
||
VStack(spacing: 16) {
|
||
Text("💰 Стоимость звонков")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
VStack(spacing: 12) {
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Аудио звонки")
|
||
.font(.headline)
|
||
Text("Голосовая связь")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
HStack(spacing: 4) {
|
||
Text("1")
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
Text("Ɉ/сек")
|
||
.font(.title3)
|
||
}
|
||
.foregroundColor(Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
|
||
HStack {
|
||
VStack(alignment: .leading, spacing: 4) {
|
||
Text("Видео звонки")
|
||
.font(.headline)
|
||
Text("Видеосвязь HD")
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
HStack(spacing: 4) {
|
||
Text("1")
|
||
.font(.title2)
|
||
.fontWeight(.bold)
|
||
Text("Ɉ/сек")
|
||
.font(.title3)
|
||
}
|
||
.foregroundColor(Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
|
||
Text("⚠️ Для звонков нужен Montana номер")
|
||
.font(.caption)
|
||
.foregroundColor(.orange)
|
||
}
|
||
.padding()
|
||
.background(Color.green.opacity(0.05))
|
||
.cornerRadius(12)
|
||
}
|
||
|
||
// MARK: - History Section
|
||
|
||
private var historySection: some View {
|
||
VStack(alignment: .leading, spacing: 12) {
|
||
Text("История звонков")
|
||
.font(.title3)
|
||
.fontWeight(.semibold)
|
||
|
||
ForEach(callHistory) { record in
|
||
HStack {
|
||
Image(systemName: record.type == .audio ? "phone.fill" : "video.fill")
|
||
.foregroundColor(Color(red: 0.0, green: 0.83, blue: 1.0))
|
||
|
||
VStack(alignment: .leading, spacing: 2) {
|
||
Text(record.recipient)
|
||
.font(.system(.body, design: .monospaced))
|
||
Text(record.timestamp, style: .relative)
|
||
.font(.caption)
|
||
.foregroundColor(.secondary)
|
||
}
|
||
|
||
Spacer()
|
||
|
||
VStack(alignment: .trailing, spacing: 2) {
|
||
Text(formatDuration(record.duration))
|
||
.font(.caption)
|
||
Text("\(record.cost) Ɉ")
|
||
.font(.caption)
|
||
.fontWeight(.semibold)
|
||
}
|
||
}
|
||
.padding()
|
||
.background(Color(NSColor.controlBackgroundColor))
|
||
.cornerRadius(8)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Actions
|
||
|
||
private func loadCallHistory() {
|
||
guard let addr = engine.address else { return }
|
||
Task { @MainActor in
|
||
do {
|
||
let events = try await engine.api.fetchMyEvents(address: addr)
|
||
callHistory = events.compactMap { event in
|
||
guard let type = event["event_type"] as? String,
|
||
(type == "call_audio" || type == "call_video"),
|
||
let metadata = event["metadata"] as? [String: Any],
|
||
let duration = metadata["duration_seconds"] as? Int,
|
||
let amount = event["amount"] as? Int,
|
||
let toAddr = event["to_address"] as? String else { return nil }
|
||
let callType: CallType = type == "call_video" ? .video : .audio
|
||
let ts = event["timestamp"] as? String ?? ""
|
||
let df = ISO8601DateFormatter()
|
||
let date = df.date(from: ts) ?? Date()
|
||
return CallRecord(recipient: toAddr, type: callType, duration: duration, cost: amount, timestamp: date)
|
||
}
|
||
} catch {
|
||
callHistory = []
|
||
}
|
||
}
|
||
}
|
||
|
||
private func loadPricing() {
|
||
Task { @MainActor in
|
||
do {
|
||
let (audio, video) = try await engine.api.fetchCallPricing()
|
||
audioPricing = audio
|
||
videoPricing = video
|
||
} catch {
|
||
audioPricing = 1
|
||
videoPricing = 1
|
||
}
|
||
}
|
||
}
|
||
|
||
private func initiateCall() {
|
||
showCallAlert = true
|
||
}
|
||
|
||
private func formatDuration(_ seconds: Int) -> String {
|
||
let minutes = seconds / 60
|
||
let secs = seconds % 60
|
||
return String(format: "%d:%02d", minutes, secs)
|
||
}
|
||
}
|
||
|
||
// MARK: - Models
|
||
|
||
struct CallRecord: Identifiable {
|
||
let id = UUID()
|
||
let recipient: String
|
||
let type: CallsView.CallType
|
||
let duration: Int // seconds
|
||
let cost: Int // Ɉ
|
||
let timestamp: Date
|
||
}
|
||
|
||
// MARK: - Preview
|
||
|
||
#Preview {
|
||
CallsView()
|
||
.environmentObject(PresenceEngine.shared)
|
||
.frame(width: 600, height: 500)
|
||
}
|