Files
bitchat/bitchat/Views/VerificationViews.swift
T
d285c6ad53 ux-fixes: lock alignment, caption band, wrapping, tap targets, VoiceOver, theme sweep (#1366)
* Fix lock glyph alignment, privacy-caption band, and empty-state wrapping

- Message-row locks: align to first text baseline instead of a hardcoded
  top padding that left the lock ~4pt below the line's visual center
- Header/caption locks: 1pt optical lift (lock.fill ink is bottom-heavy;
  geometric centering reads low); seal badge stays untouched
- DM privacy caption: sit on the themed surface like the rest of the
  bottom chrome instead of painting its own orange band
- Empty-state lines: non-breaking spaces so the closing * can't orphan
  and 'bitchat/ for help' can't break right after the slash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Unify sheet close buttons, widen tiny tap targets, handle long nicknames

- New SheetCloseButton component: one glyph size/weight (13 semibold),
  32pt visual box, 44pt hit target; adopted by all 7 sheets (sizes had
  drifted across 12/13/14pt, two had no frame at all)
- Favorite star buttons get real tap targets (peer list + DM header)
- DM header nickname: single line with middle truncation instead of
  wrapping into the fixed-height header; peer-list names truncate tail
- Geohash people rows: leading glyph 12 -> 10 to match mesh rows
- Sidebar lock glyphs get the same optical lift as the DM header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Make channel switching, voice notes, and header actions work under VoiceOver

- Channel rows in the location sheet are now single activatable buttons
  (label + selected trait + switch hint) with the bookmark toggle
  mirrored as a named accessibility action; bookmark buttons labeled
- Voice-note mic: press-and-hold drag gestures can't be activated by
  VoiceOver, so the default accessibility action now toggles
  start/stop-and-send; announces 'recording' state; localized labels
- Attachment button: camera (long-press) path exposed as a named
  action; labels localized instead of hardcoded English
- People-count button announces connected vs no-one-reachable (was
  color-only); verification QR button gains a spoken name (.help is
  only a hint on iOS); bitchat/ logo exposes its tap-for-app-info as a
  button (panic triple-tap stays undiscoverable on purpose)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Theme-correctness sweep: palette colors everywhere, AX-size header growth

- Fingerprint/verification sheet cards: palette-tinted boxes instead of
  fixed gray bands that ignored matrix green and occluded glass
- Voice-note card: palette background (translucent) instead of opaque
  white/black; waveform + payment chips + image placeholder follow suit
- .secondary/.primary/Color.blue swapped for palette.secondary/primary/
  accentBlue across location sheets, people sheets, message captions,
  and the header count (system gray read wrong under matrix green)
- Autocomplete/command rows: dropped the uniform gray wash that dulled
  the themed overlay panel
- 'tap to reveal' caption follows the theme font instead of hardcoding
  monospaced
- Headers use minHeight so two-line accessibility text sizes grow the
  bar instead of clipping inside it

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Fix main header expanding to fill the screen

The header bar's fixed height was load-bearing: its children fill the
bar with .frame(maxHeight: .infinity) tap targets, so switching to an
open-ended minHeight let the header expand to swallow all available
vertical space, centering the title mid-screen and crushing the
timeline into the composer. Restore the fixed height — headerHeight is
a @ScaledMetric, so it already grows with Dynamic Type. Reproduced and
verified both layouts with an offscreen render harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* DM header: floating glass panel instead of muddy orange wash under glass

Orange at 14% over the backdrop gradient reads as a gray-beige band,
not a privacy signature. Under liquid glass the DM header now uses the
same floating chrome panel as the main header; the private signature is
already carried by the orange lock, caption, and composer accents.
Matrix keeps its orange wash over the opaque themed surface, unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 14:22:38 +02:00

384 lines
14 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", 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", comment: "Status text when verification is requested for a nickname"),
locale: .current,
nickname
)
}
static let notFound = String(localized: "verification.scan.status.no_peer", comment: "Status when no matching peer is found for a verification request")
static let invalid = String(localized: "verification.scan.status.invalid", 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("verification.sheet.title")
.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("verification.scan.prompt_friend")
.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 }
}
}