QRCodeView.swift (18246B)
1 // 2 // QRCodeView.swift 3 // damus 4 // 5 // Created by eric on 1/27/23. 6 // 7 8 import SwiftUI 9 import CoreImage.CIFilterBuiltins 10 import CodeScanner 11 12 13 struct QRCodeView: View { 14 let damus_state: DamusState 15 @State var pubkey: Pubkey 16 17 @Environment(\.dismiss) var dismiss 18 19 @State private var selectedTab = 0 20 21 @ViewBuilder 22 func navImage(systemImage: String) -> some View { 23 Image(systemName: systemImage) 24 .frame(width: 33, height: 33) 25 .background(Color.black.opacity(0.6)) 26 .clipShape(Circle()) 27 } 28 29 var navBackButton: some View { 30 Button { 31 dismiss() 32 } label: { 33 navImage(systemImage: "chevron.left") 34 } 35 } 36 37 var customNavbar: some View { 38 HStack { 39 navBackButton 40 Spacer() 41 } 42 .padding(.top, 5) 43 .padding(.horizontal) 44 .accentColor(DamusColors.white) 45 } 46 47 var body: some View { 48 NavigationView { 49 ZStack(alignment: .center) { 50 ZStack(alignment: .topLeading) { 51 DamusGradient() 52 } 53 TabView(selection: $selectedTab) { 54 QRView 55 .tag(0) 56 self.qrCameraView 57 .tag(1) 58 } 59 .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) 60 .onAppear { 61 UIScrollView.appearance().isScrollEnabled = false 62 } 63 .gesture( 64 DragGesture() 65 .onChanged { _ in } 66 ) 67 } 68 } 69 .navigationTitle("") 70 .navigationBarHidden(true) 71 .overlay(customNavbar, alignment: .top) 72 } 73 74 var QRView: some View { 75 VStack(alignment: .center) { 76 let profile_txn = damus_state.profiles.lookup(id: pubkey, txn_name: "qrview-profile") 77 let profile = profile_txn?.unsafeUnownedValue 78 79 ProfilePicView(pubkey: pubkey, size: 90.0, highlight: .custom(DamusColors.white, 3.0), profiles: damus_state.profiles, disable_animation: damus_state.settings.disable_animation) 80 .padding(.top, 20) 81 82 if let display_name = profile?.display_name { 83 Text(display_name) 84 .font(.system(size: 24, weight: .heavy)) 85 .foregroundColor(.white) 86 } 87 if let name = profile?.name { 88 Text(verbatim: "@" + name) 89 .font(.body) 90 .foregroundColor(.white) 91 } 92 93 Spacer() 94 95 Image(uiImage: generateQRCode(pubkey: "nostr:" + pubkey.npub)) 96 .interpolation(.none) 97 .resizable() 98 .scaledToFit() 99 .frame(minWidth: 100, maxWidth: 300, minHeight: 100, maxHeight: 300) 100 .cornerRadius(10) 101 .overlay(RoundedRectangle(cornerRadius: 10) 102 .stroke(DamusColors.white, lineWidth: 5.0) 103 .scaledToFit()) 104 .shadow(radius: 10) 105 106 Spacer() 107 108 // apply the same styling to both text-views without code duplication 109 Group { 110 if damus_state.pubkey.npub == pubkey.npub { 111 Text("Follow me on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.") 112 } else { 113 Text("Follow \(profile?.display_name ?? profile?.name ?? "") on Nostr", comment: "Text on QR code view to prompt viewer looking at screen to follow the user.") 114 } 115 } 116 .font(.system(size: 24, weight: .heavy)) 117 .padding(.top, 10) 118 .foregroundColor(.white) 119 120 Text("Scan the code", comment: "Text on QR code view to prompt viewer to scan the QR code on screen with their device camera.") 121 .font(.system(size: 18, weight: .ultraLight)) 122 .foregroundColor(.white) 123 124 Spacer() 125 126 Button(action: { 127 selectedTab = 1 128 }) { 129 HStack { 130 Text("Scan Code", comment: "Button to switch to scan QR Code page.") 131 .fontWeight(.semibold) 132 } 133 .frame(minWidth: 300, maxWidth: .infinity, maxHeight: 12, alignment: .center) 134 } 135 .buttonStyle(GradientButtonStyle()) 136 .padding(20) 137 } 138 } 139 140 var qrCameraView: some View { 141 QRCameraView(damusState: damus_state, bottomContent: { 142 Button(action: { 143 selectedTab = 0 144 }) { 145 HStack { 146 Text("View QR Code", comment: "Button to switch to view users QR Code") 147 .fontWeight(.semibold) 148 } 149 .frame(maxWidth: .infinity, maxHeight: 12, alignment: .center) 150 } 151 .buttonStyle(GradientButtonStyle()) 152 .padding(50) 153 }, dismiss: dismiss) 154 } 155 156 func generateQRCode(pubkey: String) -> UIImage { 157 let data = pubkey.data(using: String.Encoding.ascii) 158 let qrFilter = CIFilter(name: "CIQRCodeGenerator") 159 qrFilter?.setValue(data, forKey: "inputMessage") 160 let qrImage = qrFilter?.outputImage 161 162 let colorInvertFilter = CIFilter(name: "CIColorInvert") 163 colorInvertFilter?.setValue(qrImage, forKey: "inputImage") 164 let outputInvertedImage = colorInvertFilter?.outputImage 165 166 let maskToAlphaFilter = CIFilter(name: "CIMaskToAlpha") 167 maskToAlphaFilter?.setValue(outputInvertedImage, forKey: "inputImage") 168 let outputCIImage = maskToAlphaFilter?.outputImage 169 170 let context = CIContext() 171 let cgImage = context.createCGImage(outputCIImage!, from: outputCIImage!.extent)! 172 return UIImage(cgImage: cgImage) 173 } 174 } 175 176 /// A view that scans for pubkeys/npub QR codes and displays a profile when needed. 177 /// 178 /// ## Implementation notes: 179 /// 180 /// - Marked as `fileprivate` since it is a relatively niche view, but can be made public with some adaptation if reuse is needed 181 /// - The main state is tracked by a single enum, to ensure mutual exclusion of states (only one of the states can be active at a time), and that the info for each state is there when needed — both enforced at compile-time 182 fileprivate struct QRCameraView<Content: View>: View { 183 184 // MARK: Input parameters 185 186 var damusState: DamusState 187 /// A custom view to display on the bottom of the camera view 188 var bottomContent: () -> Content 189 var dismiss: DismissAction 190 191 192 // MARK: State properties 193 194 /// The main state of this view. 195 @State var scannerState: ScannerState = .scanning { 196 didSet { 197 switch (oldValue, scannerState) { 198 case (.scanning, .scanSuccessful), (.incompatibleQRCodeFound, .scanSuccessful): 199 generator.impactOccurred() // Haptic feedback upon a successful scan 200 default: 201 break 202 } 203 } 204 } 205 206 207 // MARK: Helper properties and objects 208 209 let generator = UIImpactFeedbackGenerator(style: .light) 210 /// A timer that ticks every second. 211 /// We need this to dismiss the incompatible QR code message automatically once the user is no longer pointing the camera at it 212 let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect() 213 214 /// This is used to create a nice border animation when a scan is successful 215 /// 216 /// Computed property to simplify state management 217 var outerTrimEnd: CGFloat { 218 switch scannerState { 219 case .scanning, .error, .incompatibleQRCodeFound: 220 return 0.0 221 case .scanSuccessful: 222 return 1.0 223 } 224 } 225 226 /// A computed binding that indicates if there is an error to be displayed. 227 /// 228 /// This property is computed based on the main state `scannerState`, and is used to manage the error sheet without adding any extra state variables 229 var errorBinding: Binding<ScannerError?> { 230 Binding( 231 get: { 232 guard case .error(let error) = scannerState else { return nil } 233 return error 234 }, 235 set: { newError in 236 guard let newError else { 237 self.scannerState = .scanning 238 return 239 } 240 self.scannerState = .error(newError) 241 }) 242 } 243 244 /// A computed binding that indicates if there is a profile scan result to be displayed 245 /// 246 /// This property is computed based on the main state `scannerState`, and is used to manage the profile sheet without adding any extra state variables 247 var profileScanResultBinding: Binding<ProfileScanResult?> { 248 Binding( 249 get: { 250 guard case .scanSuccessful(result: let scanResult) = scannerState else { return nil } 251 return scanResult 252 }, 253 set: { newProfileScanResult in 254 guard let newProfileScanResult else { 255 self.scannerState = .scanning 256 return 257 } 258 self.scannerState = .scanSuccessful(result: newProfileScanResult) 259 }) 260 } 261 262 263 // MARK: View layouts 264 265 var body: some View { 266 VStack(alignment: .center) { 267 Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.") 268 .padding(.top, 50) 269 .font(.system(size: 24, weight: .heavy)) 270 .foregroundColor(.white) 271 272 Spacer() 273 274 CodeScannerView(codeTypes: [.qr], scanMode: .continuous, scanInterval: 1, showViewfinder: true, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in 275 self.handleNewProfileScanInfo(result) 276 } 277 .scaledToFit() 278 .frame(maxWidth: 300, maxHeight: 300) 279 .cornerRadius(10) 280 .overlay(RoundedRectangle(cornerRadius: 10).stroke(DamusColors.white, lineWidth: 5.0).scaledToFit()) 281 .overlay(RoundedRectangle(cornerRadius: 10).trim(from: 0.0, to: outerTrimEnd).stroke(DamusColors.black, lineWidth: 5.5) 282 .rotationEffect(.degrees(-90)).scaledToFit()) 283 .shadow(radius: 10) 284 285 Spacer() 286 287 self.hintMessage 288 289 Spacer() 290 291 self.bottomContent() 292 } 293 // Show an error sheet if we are on an error state 294 .sheet(item: self.errorBinding, content: { error in 295 self.errorSheet(error: error) 296 }) 297 // Show the profile sheet if we have successfully scanned 298 .sheet(item: self.profileScanResultBinding, content: { scanResult in 299 ProfileActionSheetView(damus_state: self.damusState, pubkey: scanResult.pubkey, onNavigate: { 300 dismiss() 301 }) 302 .tint(DamusColors.adaptableBlack) 303 .presentationDetents([.large]) 304 }) 305 // Dismiss an incompatible QR code message automatically after a second or two of pointing it elsewhere. 306 .onReceive(timer) { _ in 307 switch self.scannerState { 308 case .incompatibleQRCodeFound(scannedAt: let date): 309 if abs(date.timeIntervalSinceNow) > 1.5 { 310 self.scannerState = .scanning 311 } 312 default: 313 break 314 } 315 } 316 } 317 318 var hintMessage: some View { 319 HStack { 320 switch self.scannerState { 321 case .scanning: 322 Text("Point your camera to a QR code…", comment: "Text on QR code camera view instructing user to point to QR code") 323 case .incompatibleQRCodeFound: 324 Text("Sorry, this QR code looks incompatible with Damus. Please try another one.", comment: "Text on QR code camera view telling the user a QR is incompatible") 325 case .scanSuccessful: 326 Text("Found profile!", comment: "Text on QR code camera view telling user that profile scan was successful.") 327 case .error: 328 Text("Error, please try again", comment: "Text on QR code camera view indicating an error") 329 } 330 } 331 .foregroundColor(.white) 332 .padding() 333 } 334 335 func errorSheet(error: ScannerError) -> some View { 336 VStack(spacing: 10) { 337 Image(systemName: "exclamationmark.circle.fill") 338 Text("Error", comment: "Headline label for an error sheet on the QR code scanner") 339 .font(.headline) 340 Text(error.localizedDescription) 341 } 342 .presentationDetents([.medium]) 343 .tint(DamusColors.adaptableBlack) 344 } 345 346 347 // MARK: Scanning and state management logic 348 349 /// A base handler anytime the scanner sends new info, 350 /// 351 /// Behavior depends on the current state. In some states we completely ignore new scanner info (e.g. when looking at a profile) 352 /// This function mutates our state 353 func handleNewProfileScanInfo(_ scanInfo: Result<ScanResult, ScanError>) { 354 switch scannerState { 355 case .scanning, .incompatibleQRCodeFound: 356 withAnimation { 357 self.scannerState = self.processScanAndComputeNextState(scanInfo) 358 } 359 case .scanSuccessful, .error: 360 return // We don't want new scan results to pop-up while in these states 361 } 362 } 363 364 /// Processes a QR code scan, and computes the next state to be applied to the view 365 func processScanAndComputeNextState(_ scanInfo: Result<ScanResult, ScanError>) -> ScannerState { 366 switch scanInfo { 367 case .success(let successfulScan): 368 guard let result = ProfileScanResult(string: successfulScan.string) else { 369 return .incompatibleQRCodeFound(scannedAt: Date.now) 370 } 371 return .scanSuccessful(result: result) 372 case .failure(let error): 373 return .error(.scanError(error)) 374 } 375 } 376 377 // MARK: Helper types 378 379 /// A custom type for `QRCameraView` to track the state of the scanner. 380 /// 381 /// This is done to avoid having multiple independent variables to track the state, which increases the chance of state inconsistency. 382 /// By using this we guarantee at compile-time that we will always be in one state at a time, and that the state is coherent/consistent/clear. 383 enum ScannerState { 384 /// Camera is on and actively scanning new QR codes 385 case scanning 386 /// Scan and decoding was successful. Show profile. 387 case scanSuccessful(result: ProfileScanResult) 388 /// Tell the user they scanned a QR code that is incompatible 389 case incompatibleQRCodeFound(scannedAt: Date) 390 /// There was an error. Display a human readable and actionable message 391 case error(ScannerError) 392 } 393 394 /// Represents an error in this view, to be displayed to the user 395 /// 396 /// **Implementation notes:** 397 /// 1. This is identifiable because it that is needed for the error sheet view 398 /// 2. Currently there is only one error type (`ScanError`), but this is still used to allow us to customize it and add future error types outside the scanner. 399 enum ScannerError: Error, Identifiable { 400 case scanError(ScanError) 401 402 var localizedDescription: String { 403 switch self { 404 case .scanError(let scanError): 405 switch scanError { 406 case .badInput: 407 NSLocalizedString("The camera could not be accessed.", comment: "Camera's bad input error label") 408 case .badOutput: 409 NSLocalizedString("The camera was not capable of scanning the requested codes.", comment: "Camera's bad output error label") 410 case .initError(_): 411 NSLocalizedString("There was an unexpected error in initializing the camera.", comment: "Camera's initialization error label") 412 case .permissionDenied: 413 NSLocalizedString("Camera's permission was denied. You can change this in iOS settings.", comment: "Camera's permission denied error label") 414 } 415 } 416 } 417 var id: String { return self.localizedDescription } 418 } 419 420 /// A struct that holds results of a profile scan 421 struct ProfileScanResult: Equatable, Identifiable { 422 var id: Pubkey { return self.pubkey } 423 let pubkey: Pubkey 424 425 init?(hex: String) { 426 guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else { 427 return nil 428 } 429 430 self.pubkey = pk 431 } 432 433 init?(string: String) { 434 var str = string.trimmingCharacters(in: ["\n", "\t", " "]) 435 guard str.count != 0 else { 436 return nil 437 } 438 439 if str.hasPrefix("nostr:") { 440 str.removeFirst("nostr:".count) 441 } 442 443 if let decoded = hex_decode(str), 444 str.count == 64 445 { 446 self.pubkey = Pubkey(Data(decoded)) 447 return 448 } 449 450 if str.starts(with: "npub"), 451 let b32 = try? bech32_decode(str) 452 { 453 self.pubkey = Pubkey(b32.data) 454 return 455 } 456 457 return nil 458 } 459 } 460 } 461 462 463 // MARK: - Previews 464 465 struct QRCodeView_Previews: PreviewProvider { 466 static var previews: some View { 467 QRCodeView(damus_state: test_damus_state, pubkey: test_note.pubkey) 468 } 469 } 470