damus

nostr ios client
git clone git://jb55.com/damus
Log | Files | Refs | README | LICENSE

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