import CoreImage.CIFilterBuiltins import DesignSystem import Intercom import SwiftUI /// Intercom on the control: QR code for the phones, who is connected, what they see and hear. struct IntercomView: View { let intercom: IntercomController let sourceList: SourceListModel let audio: AudioController @Environment(\.openSettings) private var openSettings private var server: IntercomServer { intercom.server } var body: some View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.card) { if !intercom.isEnabled { VStack(alignment: .leading, spacing: Theme.Space.unit) { Text("Intercom ist aus. Wähl in den Einstellungen das Klinik-Netz, dann verbinden sich Handys per QR-Code.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) Button("Einstellungen öffnen") { openSettings() } } } else { if let error = server.error { Text(error).font(Theme.Font.body).foregroundStyle(Theme.Color.kohaku) } if intercom.sharesTechnicalInterface { Text("Technik- und Klinik-Netz laufen über dieselbe Schnittstelle. Zum Testen okay, im Einsatz müssen sie getrennt sein.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.kohaku) } HStack(alignment: .top, spacing: Theme.Space.card) { joinCard VStack(alignment: .leading, spacing: Theme.Space.card) { sourcesCard participantsCard } } } } .padding(Theme.Space.card) .frame(maxWidth: .infinity, alignment: .leading) } .background(Theme.Color.sumi) } private var joinCard: some View { Card { Text("Handy verbinden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi) if let url = server.url { QRCode(text: url.absoluteString) .frame(width: 200, height: 200) Text(verbatim: url.absoluteString) .font(Theme.Font.number) .lineLimit(1) .minimumScaleFactor(0.6) .foregroundStyle(Theme.Color.washi) .textSelection(.enabled) Text("Handy ins Klinik-Netz, QR-Code scannen, Name und Rolle eintragen.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.nezumi) .fixedSize(horizontal: false, vertical: true) } else { Text("Startet …").font(Theme.Font.body).foregroundStyle(Theme.Color.nezumi) } } .frame(width: 240) } private var sourcesCard: some View { Card { Picker("Vorschau", selection: Binding(get: { intercom.previewSourceName }, set: { intercom.previewSourceName = $0 })) { Text("Keine").tag("") ForEach(sourceList.sources, id: \.ndiName) { source in Text(verbatim: source.ndiName).tag(source.ndiName) } if !intercom.previewSourceName.isEmpty, !sourceList.sources.contains(where: { $0.ndiName == intercom.previewSourceName }) { Text(verbatim: "\(intercom.previewSourceName) · nicht gefunden").tag(intercom.previewSourceName) } } Picker("Talkback", selection: Binding(get: { intercom.talkbackDeviceUID }, set: { intercom.talkbackDeviceUID = $0 })) { if let name = intercom.automaticTalkbackName { Text("Automatisch · \(name)").tag("") } else { Text("Automatisch").tag("") } ForEach(audio.matrix.inputDevices) { device in Text(verbatim: device.name).tag(device.uid) } } Text("Handys hören das Mikro, solange die Sprechtaste (\(audio.talkKey.displayName)) gedrückt ist.") .font(Theme.Font.caption) .foregroundStyle(Theme.Color.nezumi) } } private var participantsCard: some View { Card { HStack { Text("Verbunden").font(Theme.Font.title).foregroundStyle(Theme.Color.washi) Spacer() Text(verbatim: "\(server.participants.filter(\.connected).count)") .font(Theme.Font.number) .foregroundStyle(Theme.Color.nezumi) } if server.participants.isEmpty { Text("Noch niemand da. Scann den QR-Code mit einem Handy.") .font(Theme.Font.body) .foregroundStyle(Theme.Color.nezumi) } ForEach(server.participants) { participant in HStack(spacing: Theme.Space.unit) { Circle() .fill(participant.connected ? Theme.Color.moegi : Theme.Color.kohaku) .frame(width: 8, height: 8) Text(verbatim: participant.name).font(Theme.Font.body).foregroundStyle(Theme.Color.washi) Text(verbatim: participant.role).font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) Spacer() if !participant.connected { Text("verbindet …").font(Theme.Font.caption).foregroundStyle(Theme.Color.nezumi) } } } } } } private struct Card: View { @ViewBuilder let content: Content var body: some View { VStack(alignment: .leading, spacing: Theme.Space.unit) { content } .padding(Theme.Space.card) .frame(maxWidth: .infinity, alignment: .leading) .background(Theme.Color.kasumi, in: RoundedRectangle(cornerRadius: Theme.Radius.card)) .overlay(RoundedRectangle(cornerRadius: Theme.Radius.card).stroke(Theme.Color.usuzumi, lineWidth: Theme.Stroke.line)) } } /// Black on white with a quiet zone: what phone cameras read best, regardless of the dark theme. private struct QRCode: View { let text: String var body: some View { if let image = Self.image(for: text) { Image(decorative: image, scale: 1) .interpolation(.none) .resizable() .padding(Theme.Space.unit) .background(.white, in: RoundedRectangle(cornerRadius: Theme.Radius.button)) } } static func image(for text: String) -> CGImage? { let filter = CIFilter.qrCodeGenerator() filter.message = Data(text.utf8) filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } return CIContext().createCGImage(output, from: output.extent) } }