1// LinkedDevicesView.swift2// SophaxChat3//4// Manage devices linked to this account (same person, multiple devices).5// Device B scans Device A's QR code; both sides then sync 1-to-1 messages.67import SwiftUI8import CoreImage.CIFilterBuiltins9import SophaxChatCore1011struct LinkedDevicesView: View {12@EnvironmentObject var appState: AppState13@State private var showingQR = false14@State private var showingScanner = false15@State private var deviceToUnlink: KnownPeer?= nil1617 var body: some View {18 List {19 Section {20 Button {21 showingQR = true22} label:{23 Label("Show Link QR Code", systemImage:"qrcode")24}25 Button {26 showingScanner = true27} label:{28 Label("Scan Another Device's QR", systemImage:"qrcode.viewfinder")29}30} footer:{31 Text("To link two devices, open SophaxChat on both. On Device A tap \"Show Link QR Code\", then on Device B tap \"Scan Another Device's QR\" and point the camera at Device A's screen.")32.font(.caption2)33}3435 if !appState.linkedDevices.isEmpty {36 Section("Linked Devices"){37 ForEach(appState.linkedDevices){ peer in38 HStack(spacing: 12){39 Image(systemName:"iphone")40.font(.title2)41.foregroundStyle(.secondary)42.frame(width: 36)43 VStack(alignment:.leading, spacing: 2){44 Text(peer.username)45.font(.subheadline.weight(.medium))46 Text(String(peer.id.prefix(8))+"…")47.font(.caption2)48.foregroundStyle(.secondary)49 if !peer.isOnline, let seen = peer.lastSeen {50 Text("Last seen \(seen, style: .relative) ago")51.font(.caption2)52.foregroundStyle(.tertiary)53}54}55 Spacer()56 if peer.isOnline {57 Circle().fill(.green).frame(width: 8, height: 8)58}59}60.swipeActions(edge:.trailing){61 Button(role:.destructive){62 deviceToUnlink = peer63} label:{64 Label("Unlink", systemImage:"link.badge.minus")65}66}67}68}69}70}71.navigationTitle("Linked Devices")72.navigationBarTitleDisplayMode(.inline)73.sheet(isPresented: $showingQR){74 DeviceLinkQRSheet()75}76.sheet(isPresented: $showingScanner){77 DeviceLinkScannerSheet()78}79.confirmationDialog(80"Unlink \"\(deviceToUnlink?.username ?? "")\"?",81 isPresented: Binding(get:{ deviceToUnlink != nil }, set:{ if !$0 { deviceToUnlink = nil }}),82 titleVisibility:.visible83){84 Button("Unlink Device", role:.destructive){85 if let peer = deviceToUnlink { appState.unlinkDevice(peer)}86 deviceToUnlink = nil87}88 Button("Cancel", role:.cancel){ deviceToUnlink = nil }89} message:{90 Text("Messages will no longer be forwarded to this device. This cannot be undone.")91}92}93}9495// MARK: - QR Sheet (this device shows its link payload)9697private struct DeviceLinkQRSheet: View {98@EnvironmentObject var appState: AppState99@Environment(\.dismiss) private var dismiss100101@State private var qrData: Data?= nil102@State private var secondsLeft: Int = 0103 private let totalSeconds = 600 // 10-minute window104105 private func makeQRImage(from data: Data)-> Image?{106 guard let string = String(data: data, encoding:.utf8) else { return nil }107 let ctx = CIContext()108 let filter = CIFilter.qrCodeGenerator()109 filter.message = Data(string.utf8)110 filter.correctionLevel ="M"111 guard let output = filter.outputImage else { return nil }112 let scaled = output.transformed(by: CGAffineTransform(scaleX: 10, y: 10))113 guard let cgImg = ctx.createCGImage(scaled, from: scaled.extent) else { return nil }114 return Image(uiImage: UIImage(cgImage: cgImg))115}116117 private func refresh(){118 qrData = appState.generateDeviceLinkQR()119 secondsLeft = totalSeconds120}121122 private var countdownLabel: String {123 let m = secondsLeft / 60124 let s = secondsLeft % 60125 return String(format:"%d:%02d", m, s)126}127128 var body: some View {129 NavigationStack {130 VStack(spacing: 24){131 Text("Scan this QR with your other device")132.font(.subheadline)133.foregroundStyle(.secondary)134.multilineTextAlignment(.center)135136 if let data = qrData, let img = makeQRImage(from: data){137 img138.interpolation(.none)139.resizable()140.scaledToFit()141.frame(width: 260, height: 260)142.padding(16)143.background(Color.white)144.clipShape(RoundedRectangle(cornerRadius: 12))145.shadow(radius: 4)146} else {147 ProgressView()148.frame(width: 260, height: 260)149}150151// Countdown label — turns red in the last 60 seconds152 HStack(spacing: 4){153 Image(systemName:"timer")154 Text("Expires in \(countdownLabel)")155}156.font(.caption.monospacedDigit())157.foregroundStyle(secondsLeft <= 60 ?.red :.secondary)158159 Text("Keep both devices nearby (Bluetooth range) after scanning so they can exchange keys.")160.font(.caption)161.foregroundStyle(.secondary)162.multilineTextAlignment(.center)163.padding(.horizontal)164}165.padding()166.navigationTitle("Link This Device")167.navigationBarTitleDisplayMode(.inline)168.toolbar {169 ToolbarItem(placement:.cancellationAction){170 Button("Done"){ dismiss()}171}172}173.onAppear { refresh()}174.onReceive(Timer.publish(every: 1, on:.main, in:.common).autoconnect()){ _ in175 if secondsLeft > 0 {176 secondsLeft -= 1177} else {178 refresh()// auto-regenerate when expired179}180}181}182}183}184185// MARK: - Scanner Sheet (this device scans the other's QR)186187private struct DeviceLinkScannerSheet: View {188@EnvironmentObject var appState: AppState189@Environment(\.dismiss) private var dismiss190@State private var scannedResult: String?= nil191@State private var didLink = false192193 var body: some View {194 NavigationStack {195 VStack(spacing: 16){196 if didLink {197 VStack(spacing: 16){198 Image(systemName:"checkmark.circle.fill")199.font(.system(size: 64))200.foregroundStyle(.green)201 Text("Device Linked!")202.font(.title2.weight(.semibold))203 Text("Messages will sync between your devices when they're in range.")204.font(.subheadline)205.foregroundStyle(.secondary)206.multilineTextAlignment(.center)207}208.padding()209} else {210 ContactScannerView(scannedResult: $scannedResult)211}212}213.navigationTitle("Scan Device QR")214.navigationBarTitleDisplayMode(.inline)215.toolbar {216 ToolbarItem(placement:.cancellationAction){217 Button("Cancel"){ dismiss()}218}219}220.onChange(of: scannedResult){ _, newValue in221 guard let str = newValue,222 let data = str.data(using:.utf8) else { return }223 appState.acceptDeviceLink(data)224 didLink = true225 DispatchQueue.main.asyncAfter(deadline:.now()+ 1.5){ dismiss()}226}227}228}229}