mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-07-24 22:45:19 +00:00
Non-en languages are each missing ~138 of 336 catalog keys, so a key absent from that language's table renders as the raw dot-key on device (no English fallback). This adds English defaultValues so every miss resolves to English instead of a raw key. - Class (a): 217 String(localized: "dot.key") calls missing defaultValue now carry defaultValue: "<en value>" pulled verbatim from the catalog (format specifiers preserved). - Class (b): 41 SwiftUI LocalizedStringKey literals (Text/Button/alert/ confirmationDialog/TextField) wrapped as Text(String(localized: "key", defaultValue: "en")) so they resolve to English too. Existing comments folded into the resolved String. No catalog or en values changed; Swift source only. Both schemes build. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
import SwiftUI
|
|
import CoreImage
|
|
import CoreImage.CIFilterBuiltins
|
|
#if os(iOS)
|
|
import UIKit
|
|
#else
|
|
import AppKit
|
|
#endif
|
|
|
|
/// Placeholder view to display the user's verification QR payload as text.
|
|
struct MyQRView: View {
|
|
let qrString: String
|
|
@Environment(\.colorScheme) var colorScheme
|
|
@ThemedPalette private var palette
|
|
// Palette-tinted so the box follows the theme (green under matrix)
|
|
// instead of a fixed gray band over the glass gradient.
|
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
|
|
|
private enum Strings {
|
|
static let title: LocalizedStringKey = "verification.my_qr.title"
|
|
static let accessibilityLabel = String(localized: "verification.my_qr.accessibility_label", defaultValue: "verification QR code", comment: "Accessibility label describing the verification QR code")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(spacing: 12) {
|
|
Text(Strings.title)
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
|
|
VStack(spacing: 10) {
|
|
QRCodeImage(data: qrString, size: 240)
|
|
.accessibilityLabel(Strings.accessibilityLabel)
|
|
|
|
// Non-scrolling, fully visible URL (wraps across lines)
|
|
Text(qrString)
|
|
.bitchatFont(size: 11)
|
|
.textSelection(.enabled)
|
|
.multilineTextAlignment(.leading)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
.padding(8)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
// Render a QR code image for a given string using CoreImage
|
|
struct QRCodeImage: View {
|
|
let data: String
|
|
let size: CGFloat
|
|
@ThemedPalette private var palette
|
|
|
|
private let context = CIContext()
|
|
private let filter = CIFilter.qrCodeGenerator()
|
|
|
|
private enum Strings {
|
|
static let unavailable: LocalizedStringKey = "verification.my_qr.unavailable"
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let image = generateImage() {
|
|
ImageWrapper(image: image)
|
|
.frame(width: size, height: size)
|
|
} else {
|
|
RoundedRectangle(cornerRadius: 8)
|
|
.stroke(palette.secondary.opacity(0.5), lineWidth: 1)
|
|
.frame(width: size, height: size)
|
|
.overlay(
|
|
Text(Strings.unavailable)
|
|
.bitchatFont(size: 12)
|
|
.foregroundColor(palette.secondary)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func generateImage() -> CGImage? {
|
|
let inputData = Data(data.utf8)
|
|
filter.message = inputData
|
|
filter.correctionLevel = "M"
|
|
guard let outputImage = filter.outputImage else { return nil }
|
|
let scale = max(1, Int(size / 32))
|
|
let transformed = outputImage.transformed(by: CGAffineTransform(scaleX: CGFloat(scale), y: CGFloat(scale)))
|
|
return context.createCGImage(transformed, from: transformed.extent)
|
|
}
|
|
}
|
|
|
|
// Platform-specific wrapper to display CGImage in SwiftUI
|
|
struct ImageWrapper: View {
|
|
let image: CGImage
|
|
var body: some View {
|
|
#if os(iOS)
|
|
let ui = UIImage(cgImage: image)
|
|
return Image(uiImage: ui)
|
|
.interpolation(.none)
|
|
.resizable()
|
|
#else
|
|
let ns = NSImage(cgImage: image, size: .zero)
|
|
return Image(nsImage: ns)
|
|
.interpolation(.none)
|
|
.resizable()
|
|
#endif
|
|
}
|
|
}
|
|
|
|
/// Placeholder scanner UI; real camera scanning will be added later.
|
|
struct QRScanView: View {
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@ThemedPalette private var palette
|
|
var isActive: Bool = true
|
|
var onSuccess: (() -> Void)? = nil // Called when verification succeeds
|
|
@State private var input = ""
|
|
@State private var result: String = "" // not shown for iOS scanner
|
|
@State private var lastValid: String = ""
|
|
|
|
private enum Strings {
|
|
static let pastePrompt: LocalizedStringKey = "verification.scan.paste_prompt"
|
|
static let validate: LocalizedStringKey = "verification.scan.validate"
|
|
static func requested(_ nickname: String) -> String {
|
|
String(
|
|
format: String(localized: "verification.scan.status.requested", defaultValue: "verification requested for %@", comment: "Status text when verification is requested for a nickname"),
|
|
locale: .current,
|
|
nickname
|
|
)
|
|
}
|
|
static let notFound = String(localized: "verification.scan.status.no_peer", defaultValue: "could not find matching peer", comment: "Status when no matching peer is found for a verification request")
|
|
static let invalid = String(localized: "verification.scan.status.invalid", defaultValue: "invalid or expired QR payload", comment: "Status when a scanned QR payload is invalid")
|
|
}
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
#if os(iOS)
|
|
CameraScannerView(isActive: isActive) { code in
|
|
// Deduplicate: ignore if we just processed this exact QR code
|
|
guard code != lastValid else { return }
|
|
|
|
switch verificationModel.verifyScannedPayload(code) {
|
|
case .requested:
|
|
// Successfully initiated verification; remember this QR to prevent re-scanning
|
|
lastValid = code
|
|
// Close scanner and return to "My QR" view
|
|
onSuccess?()
|
|
case .notFound, .invalid:
|
|
// Ignore invalid/no-match reads and keep scanning
|
|
break
|
|
}
|
|
}
|
|
.frame(height: 260)
|
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
|
#else
|
|
Text(Strings.pastePrompt)
|
|
.bitchatFont(size: 14, weight: .medium)
|
|
TextEditor(text: $input)
|
|
.frame(height: 100)
|
|
.border(palette.secondary.opacity(0.4))
|
|
Button(Strings.validate) {
|
|
// Deduplicate: ignore if we just processed this exact QR
|
|
guard input != lastValid else {
|
|
result = Strings.requested("") // Already processed
|
|
return
|
|
}
|
|
|
|
switch verificationModel.verifyScannedPayload(input) {
|
|
case .requested(let nickname):
|
|
result = Strings.requested(nickname)
|
|
lastValid = input
|
|
// Close scanner and return to "My QR" view
|
|
onSuccess?()
|
|
case .notFound:
|
|
result = Strings.notFound
|
|
case .invalid:
|
|
result = Strings.invalid
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
#endif
|
|
// No status text under camera per design
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
#if os(iOS)
|
|
import AVFoundation
|
|
|
|
struct CameraScannerView: UIViewRepresentable {
|
|
typealias UIViewType = PreviewView
|
|
var isActive: Bool
|
|
var onCode: (String) -> Void
|
|
|
|
func makeUIView(context: Context) -> PreviewView {
|
|
let view = PreviewView()
|
|
context.coordinator.setup(sessionOwner: view, onCode: onCode)
|
|
context.coordinator.setActive(isActive)
|
|
return view
|
|
}
|
|
|
|
func updateUIView(_ uiView: PreviewView, context: Context) {
|
|
context.coordinator.setActive(isActive)
|
|
}
|
|
|
|
func makeCoordinator() -> Coordinator { Coordinator() }
|
|
|
|
final class Coordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
|
|
private var onCode: ((String) -> Void)?
|
|
private weak var owner: PreviewView?
|
|
private let session = AVCaptureSession()
|
|
private var isRunning = false
|
|
private var permissionGranted = false
|
|
private var desiredActive = false
|
|
|
|
func setup(sessionOwner: PreviewView, onCode: @escaping (String) -> Void) {
|
|
self.owner = sessionOwner
|
|
self.onCode = onCode
|
|
session.beginConfiguration()
|
|
session.sessionPreset = .high
|
|
guard let device = AVCaptureDevice.default(for: .video),
|
|
let input = try? AVCaptureDeviceInput(device: device),
|
|
session.canAddInput(input) else { return }
|
|
session.addInput(input)
|
|
let output = AVCaptureMetadataOutput()
|
|
guard session.canAddOutput(output) else { return }
|
|
session.addOutput(output)
|
|
output.setMetadataObjectsDelegate(self, queue: DispatchQueue.main)
|
|
if output.availableMetadataObjectTypes.contains(.qr) {
|
|
output.metadataObjectTypes = [.qr]
|
|
}
|
|
session.commitConfiguration()
|
|
sessionOwner.videoPreviewLayer.session = session
|
|
// Request permission and start
|
|
AVCaptureDevice.requestAccess(for: .video) { granted in
|
|
self.permissionGranted = granted
|
|
if granted && self.desiredActive && !self.isRunning {
|
|
self.setActive(true)
|
|
}
|
|
}
|
|
}
|
|
|
|
func setActive(_ active: Bool) {
|
|
desiredActive = active
|
|
guard permissionGranted else { return }
|
|
if active && !isRunning {
|
|
isRunning = true
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
if !self.session.isRunning { self.session.startRunning() }
|
|
}
|
|
} else if !active && isRunning {
|
|
isRunning = false
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
if self.session.isRunning { self.session.stopRunning() }
|
|
}
|
|
}
|
|
}
|
|
|
|
func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
|
|
for obj in metadataObjects {
|
|
guard let m = obj as? AVMetadataMachineReadableCodeObject,
|
|
m.type == .qr,
|
|
let str = m.stringValue else { continue }
|
|
onCode?(str)
|
|
}
|
|
}
|
|
}
|
|
|
|
final class PreviewView: UIView {
|
|
override static var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
|
|
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
|
|
override init(frame: CGRect) {
|
|
super.init(frame: frame)
|
|
videoPreviewLayer.videoGravity = .resizeAspectFill
|
|
}
|
|
required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
|
|
}
|
|
}
|
|
#endif
|
|
|
|
// Combined sheet: shows my QR by default with a button to scan instead
|
|
struct VerificationSheetView: View {
|
|
@EnvironmentObject private var verificationModel: VerificationModel
|
|
@Binding var isPresented: Bool
|
|
@State private var showingScanner = false
|
|
@ThemedPalette private var palette
|
|
|
|
private var backgroundColor: Color { palette.background }
|
|
private var accentColor: Color { palette.accent }
|
|
private var boxColor: Color { palette.secondary.opacity(0.1) }
|
|
|
|
var body: some View {
|
|
VStack(spacing: 0) {
|
|
// Top header (always at top)
|
|
HStack {
|
|
Text(String(localized: "verification.sheet.title", defaultValue: "VERIFY"))
|
|
.bitchatFont(size: 14, weight: .bold)
|
|
.foregroundColor(accentColor)
|
|
Spacer()
|
|
SheetCloseButton {
|
|
showingScanner = false
|
|
isPresented = false
|
|
}
|
|
.foregroundColor(accentColor)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 12)
|
|
.padding(.bottom, 8)
|
|
|
|
Divider()
|
|
|
|
// Content area
|
|
Group {
|
|
if showingScanner {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text(String(localized: "verification.scan.prompt_friend", defaultValue: "scan a friend's QR"))
|
|
.bitchatFont(size: 16, weight: .bold)
|
|
.frame(maxWidth: .infinity)
|
|
.multilineTextAlignment(.center)
|
|
.foregroundColor(accentColor)
|
|
#if os(iOS)
|
|
QRScanView(isActive: showingScanner, onSuccess: {
|
|
showingScanner = false
|
|
})
|
|
.environmentObject(verificationModel)
|
|
.frame(height: 280)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
|
#else
|
|
QRScanView(onSuccess: {
|
|
showingScanner = false
|
|
})
|
|
.environmentObject(verificationModel)
|
|
#endif
|
|
}
|
|
.padding()
|
|
.frame(maxWidth: .infinity)
|
|
.background(boxColor)
|
|
.cornerRadius(8)
|
|
} else {
|
|
MyQRView(qrString: verificationModel.myQRString())
|
|
}
|
|
}
|
|
.padding(16)
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
|
|
|
|
// Centered controls moved up
|
|
VStack(spacing: 10) {
|
|
if showingScanner {
|
|
Button(action: { showingScanner = false }) {
|
|
Label("show my qr", systemImage: "qrcode")
|
|
.bitchatFont(size: 13)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
} else {
|
|
Button(action: { showingScanner = true }) {
|
|
Label("scan someone else's qr", systemImage: "camera.viewfinder")
|
|
.bitchatFont(size: 13, weight: .medium)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.gray)
|
|
}
|
|
|
|
// Optional: Remove verification for selected peer (if verified)
|
|
if let peerID = verificationModel.selectedPeerID,
|
|
verificationModel.isVerified(peerID: peerID) {
|
|
Button(action: { verificationModel.unverifyFingerprint(for: peerID) }) {
|
|
Label("remove verification", systemImage: "minus.circle")
|
|
.bitchatFont(size: 12)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.tint(.gray)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
}
|
|
.themedSheetBackground()
|
|
.onDisappear { showingScanner = false }
|
|
}
|
|
}
|