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