commit 50ef6600a8be26b158bde2f1ca6988e0e76c0f18
parent be43819de2b1183d2f739c87de436d27ba025935
Author: Daniel D’Aquino <daniel@daquino.me>
Date: Wed, 6 Nov 2024 17:39:32 -0800
Make QR code scanning more robust
1. Removed the dependency on finding the profile event for displaying actions to the user, even if the full profile couldn't be loaded. This allowed showing useful options such as the option to follow that pubkey.
2. Opened a profile preview sheet instead of navigating to the full profile page, enabling quick actions and saving bandwidth by not loading their timeline immediately.
3. Refactored most of that view to simplify state management and make it less prone to errors.
4. Improved error handling and management.
5. Ensured the view truly reflected the internal state of the scanner to the user.
Changelog-Fixed: Fixed some issues where QR code would not work, and improved UX
Closes: https://github.com/damus-io/damus/issues/2032
Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
Diffstat:
10 files changed, 349 insertions(+), 644 deletions(-)
diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj
@@ -227,9 +227,6 @@
4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09612A098D0E00943473 /* WalletConnect.swift */; };
4C7D09662A0AE62100943473 /* AlbyButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09652A0AE62100943473 /* AlbyButton.swift */; };
4C7D09682A0AE9B200943473 /* NWCScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */; };
- 4C7D096D2A0AEA0400943473 /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */; };
- 4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */; };
- 4C7D096F2A0AEA0400943473 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */; };
4C7D09722A0AEF5E00943473 /* DamusGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09712A0AEF5E00943473 /* DamusGradient.swift */; };
4C7D09742A0AEF9000943473 /* AlbyGradient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09732A0AEF9000943473 /* AlbyGradient.swift */; };
4C7D09762A0AF19E00943473 /* FillAndStroke.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09752A0AF19E00943473 /* FillAndStroke.swift */; };
@@ -579,6 +576,8 @@
D703D7B72C67118F00A400EA /* StringUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A8CC6CB2A2CFEF900940F5F /* StringUtil.swift */; };
D703D7B82C6711A000A400EA /* NativeObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C32B9462A9AD44700DC3548 /* NativeObject.swift */; };
D70A3B172B02DCE5008BD568 /* NotificationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D70A3B162B02DCE5008BD568 /* NotificationFormatter.swift */; };
+ D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D90972CDED61800CD0534 /* CodeScanner */; };
+ D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = D70D909B2CDED7B200CD0534 /* CodeScanner */; };
D7100C562B76F8E600C59298 /* PurpleViewPrimitives.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C552B76F8E600C59298 /* PurpleViewPrimitives.swift */; };
D7100C582B76FC8400C59298 /* MarketingContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C572B76FC8400C59298 /* MarketingContentView.swift */; };
D7100C5A2B76FD5100C59298 /* LogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7100C592B76FD5100C59298 /* LogoView.swift */; };
@@ -774,9 +773,6 @@
D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
- D73E5ECE2C6A97F4007EB227 /* CodeScanner.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */; };
- D73E5ECF2C6A97F4007EB227 /* ScannerCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */; };
- D73E5ED02C6A97F4007EB227 /* ScannerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */; };
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D095D2A098C5D00943473 /* WalletView.swift */; };
D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C7D09672A0AE9B200943473 /* NWCScannerView.swift */; };
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8D1A6E29F31E5000ACDF75 /* FriendsButton.swift */; };
@@ -1659,9 +1655,6 @@
4C7D09612A098D0E00943473 /* WalletConnect.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletConnect.swift; sourceTree = "<group>"; };
4C7D09652A0AE62100943473 /* AlbyButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyButton.swift; sourceTree = "<group>"; };
4C7D09672A0AE9B200943473 /* NWCScannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NWCScannerView.swift; sourceTree = "<group>"; };
- 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CodeScanner.swift; sourceTree = "<group>"; };
- 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerCoordinator.swift; sourceTree = "<group>"; };
- 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScannerViewController.swift; sourceTree = "<group>"; };
4C7D09712A0AEF5E00943473 /* DamusGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusGradient.swift; sourceTree = "<group>"; };
4C7D09732A0AEF9000943473 /* AlbyGradient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbyGradient.swift; sourceTree = "<group>"; };
4C7D09752A0AF19E00943473 /* FillAndStroke.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FillAndStroke.swift; sourceTree = "<group>"; };
@@ -2021,6 +2014,7 @@
files = (
4C06670428FC7EC500038D2A /* Kingfisher in Frameworks */,
3A0A30BB2C21397A00F8C9BC /* EmojiPicker in Frameworks */,
+ D70D90982CDED61800CD0534 /* CodeScanner in Frameworks */,
D78DB8592C1CE9CA00F0AB12 /* SwipeActions in Frameworks */,
4C649881286E0EE300EAE2B3 /* secp256k1 in Frameworks */,
4C27C9322A64766F007DBC75 /* MarkdownUI in Frameworks */,
@@ -2052,6 +2046,7 @@
D73E5F762C6A997E007EB227 /* EmojiPicker in Frameworks */,
D703D7192C66E47100A400EA /* UniformTypeIdentifiers.framework in Frameworks */,
D703D7492C6709B100A400EA /* secp256k1 in Frameworks */,
+ D70D909C2CDED7B200CD0534 /* CodeScanner in Frameworks */,
D73E5F9B2C6AA8B0007EB227 /* Kingfisher in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
@@ -2564,7 +2559,6 @@
BA3759952ABCCF360018D73B /* Camera */,
F71694E82A66221E001F4053 /* Onboarding */,
4C190F232A547D1700027FD5 /* NostrScript */,
- 4C7D09692A0AEA0400943473 /* CodeScanner */,
4C7D095A2A098C5C00943473 /* Wallet */,
4C8D1A6D29F31E4100ACDF75 /* Buttons */,
4C1A9A2829DDF53B00516EAC /* Video */,
@@ -2672,16 +2666,6 @@
path = Wallet;
sourceTree = "<group>";
};
- 4C7D09692A0AEA0400943473 /* CodeScanner */ = {
- isa = PBXGroup;
- children = (
- 4C7D096A2A0AEA0400943473 /* CodeScanner.swift */,
- 4C7D096B2A0AEA0400943473 /* ScannerCoordinator.swift */,
- 4C7D096C2A0AEA0400943473 /* ScannerViewController.swift */,
- );
- path = CodeScanner;
- sourceTree = "<group>";
- };
4C7D09702A0AEF4C00943473 /* Gradients */ = {
isa = PBXGroup;
children = (
@@ -3463,6 +3447,7 @@
4C27C9312A64766F007DBC75 /* MarkdownUI */,
3A0A30BA2C21397A00F8C9BC /* EmojiPicker */,
D78DB8582C1CE9CA00F0AB12 /* SwipeActions */,
+ D70D90972CDED61800CD0534 /* CodeScanner */,
);
productName = damus;
productReference = 4CE6DEE327F7A08100C66700 /* damus.app */;
@@ -3519,6 +3504,7 @@
buildRules = (
);
dependencies = (
+ D70D909A2CDED78400CD0534 /* PBXTargetDependency */,
D703D7AD2C670FA700A400EA /* PBXTargetDependency */,
);
name = HighlighterActionExtension;
@@ -3528,6 +3514,7 @@
D73E5F752C6A997E007EB227 /* EmojiPicker */,
D73E5F9A2C6AA8B0007EB227 /* Kingfisher */,
D73E5F9C2C6AA8E3007EB227 /* SwipeActions */,
+ D70D909B2CDED7B200CD0534 /* CodeScanner */,
);
productName = "highlighter action extension";
productReference = D703D7172C66E47100A400EA /* HighlighterActionExtension.appex */;
@@ -3632,6 +3619,7 @@
D7A343EC2AD0D77C00CED48B /* XCRemoteSwiftPackageReference "swift-snapshot-testing" */,
3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */,
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */,
+ D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */,
);
productRefGroup = 4CE6DEE427F7A08100C66700 /* Products */;
projectDirPath = "";
@@ -3851,7 +3839,6 @@
4C75EFA627FF87A20006080F /* Nostr.swift in Sources */,
4CA927672A290F8B0098A105 /* RelativeTime.swift in Sources */,
4CB883A62975F83C00DC99E7 /* LNUrlPayRequest.swift in Sources */,
- 4C7D096D2A0AEA0400943473 /* CodeScanner.swift in Sources */,
D7CB5D4B2B11721600AD4105 /* ZapType.swift in Sources */,
4CE4F9DE2852768D00C00DD9 /* ConfigView.swift in Sources */,
3A48E7B029DFBE9D006E787E /* MutedThreadsManager.swift in Sources */,
@@ -3888,7 +3875,6 @@
4C3EA66828FF5F9900C48A62 /* hex.c in Sources */,
E9E4ED0B295867B900DD7078 /* ThreadView.swift in Sources */,
4CD348EF29C3659D00497EB2 /* ImageUploadModel.swift in Sources */,
- 4C7D096E2A0AEA0400943473 /* ScannerCoordinator.swift in Sources */,
4C3BEFDC281DCE6100B3DE84 /* Liked.swift in Sources */,
4CF0ABE7298444FD00D66079 /* EventMutingContainerView.swift in Sources */,
9C83F89329A937B900136C08 /* TextViewWrapper.swift in Sources */,
@@ -4087,7 +4073,6 @@
4CA927612A290E340098A105 /* EventShell.swift in Sources */,
4C363AA428296DEE006E126D /* SearchModel.swift in Sources */,
4C8D00CC29DF92DF0036AF10 /* Hashtags.swift in Sources */,
- 4C7D096F2A0AEA0400943473 /* ScannerViewController.swift in Sources */,
4CEE2AF3280B25C500AB5EEF /* ProfilePicView.swift in Sources */,
4CC7AAF6297F1A6A00430951 /* EventBody.swift in Sources */,
D76556D62B1E6C08001B0CCC /* DamusPurpleWelcomeView.swift in Sources */,
@@ -4451,9 +4436,6 @@
D73E5ECA2C6A97F4007EB227 /* OnboardingSuggestionsView.swift in Sources */,
D73E5ECB2C6A97F4007EB227 /* SuggestedUserView.swift in Sources */,
D73E5ECC2C6A97F4007EB227 /* SuggestedUsersViewModel.swift in Sources */,
- D73E5ECE2C6A97F4007EB227 /* CodeScanner.swift in Sources */,
- D73E5ECF2C6A97F4007EB227 /* ScannerCoordinator.swift in Sources */,
- D73E5ED02C6A97F4007EB227 /* ScannerViewController.swift in Sources */,
D73E5ED22C6A97F4007EB227 /* WalletView.swift in Sources */,
D73E5ED32C6A97F4007EB227 /* NWCScannerView.swift in Sources */,
D73E5ED42C6A97F4007EB227 /* FriendsButton.swift in Sources */,
@@ -4916,6 +4898,10 @@
isa = PBXTargetDependency;
productRef = D703D7AC2C670FA700A400EA /* MarkdownUI */;
};
+ D70D909A2CDED78400CD0534 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ productRef = D70D90992CDED78400CD0534 /* CodeScanner */;
+ };
D79C4C1A2AFEB061003A41B4 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = D79C4C132AFEB061003A41B4 /* DamusNotificationService */;
@@ -5580,6 +5566,14 @@
minimumVersion = 0.2.26;
};
};
+ D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */ = {
+ isa = XCRemoteSwiftPackageReference;
+ repositoryURL = "https://github.com/twostraws/CodeScanner.git";
+ requirement = {
+ kind = revision;
+ revision = 9fa582f4b36c69c2a55bff5fb3377eb170ae273c;
+ };
+ };
D78DB8572C1CE9CA00F0AB12 /* XCRemoteSwiftPackageReference "SwipeActions" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/damus-io/SwipeActions.git";
@@ -5634,6 +5628,21 @@
package = 4C27C9302A64766F007DBC75 /* XCRemoteSwiftPackageReference "swift-markdown-ui" */;
productName = MarkdownUI;
};
+ D70D90972CDED61800CD0534 /* CodeScanner */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
+ productName = CodeScanner;
+ };
+ D70D90992CDED78400CD0534 /* CodeScanner */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
+ productName = CodeScanner;
+ };
+ D70D909B2CDED7B200CD0534 /* CodeScanner */ = {
+ isa = XCSwiftPackageProductDependency;
+ package = D70D90962CDED61800CD0534 /* XCRemoteSwiftPackageReference "CodeScanner" */;
+ productName = CodeScanner;
+ };
D73E5F752C6A997E007EB227 /* EmojiPicker */ = {
isa = XCSwiftPackageProductDependency;
package = 3A0A30B92C21397A00F8C9BC /* XCRemoteSwiftPackageReference "EmojiPicker" */;
diff --git a/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/damus.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,7 +1,15 @@
{
- "originHash" : "1b14e62192b3fa4b04a57cb4601d175b325dc16cb5f22c4c8eb975a675328637",
+ "originHash" : "534c8e58993919d5ead25ceb4788c8e492c86bc2cf5833dc651ae60a0f30169c",
"pins" : [
{
+ "identity" : "codescanner",
+ "kind" : "remoteSourceControl",
+ "location" : "https://github.com/twostraws/CodeScanner.git",
+ "state" : {
+ "revision" : "9fa582f4b36c69c2a55bff5fb3377eb170ae273c"
+ }
+ },
+ {
"identity" : "emojikit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/tyiu/EmojiKit",
diff --git a/damus/Views/CodeScanner/CodeScanner.swift b/damus/Views/CodeScanner/CodeScanner.swift
@@ -1,114 +0,0 @@
-//
-// CodeScanner.swift
-// https://github.com/twostraws/CodeScanner
-//
-// Created by Paul Hudson on 14/12/2021.
-// Copyright © 2021 Paul Hudson. All rights reserved.
-//
-
-import AVFoundation
-import SwiftUI
-
-/// An enum describing the ways CodeScannerView can hit scanning problems.
-public enum ScanError: Error {
- /// The camera could not be accessed.
- case badInput
-
- /// The camera was not capable of scanning the requested codes.
- case badOutput
-
- /// Initialization failed.
- case initError(_ error: Error)
-}
-
-/// The result from a successful scan: the string that was scanned, and also the type of data that was found.
-/// The type is useful for times when you've asked to scan several different code types at the same time, because
-/// it will report the exact code type that was found.
-public struct ScanResult {
- /// The contents of the code.
- public let string: String
-
- /// The type of code that was matched.
- public let type: AVMetadataObject.ObjectType
-}
-
-/// The operating mode for CodeScannerView.
-public enum ScanMode {
- /// Scan exactly one code, then stop.
- case once
-
- /// Scan each code no more than once.
- case oncePerCode
-
- /// Keep scanning all codes until dismissed.
- case continuous
-}
-
-/// A SwiftUI view that is able to scan barcodes, QR codes, and more, and send back what was found.
-/// To use, set `codeTypes` to be an array of things to scan for, e.g. `[.qr]`, and set `completion` to
-/// a closure that will be called when scanning has finished. This will be sent the string that was detected or a `ScanError`.
-/// For testing inside the simulator, set the `simulatedData` property to some test data you want to send back.
-public struct CodeScannerView: UIViewControllerRepresentable {
-
- public let codeTypes: [AVMetadataObject.ObjectType]
- public let scanMode: ScanMode
- public let scanInterval: Double
- public let showViewfinder: Bool
- public var simulatedData = ""
- public var shouldVibrateOnSuccess: Bool
- public var isTorchOn: Bool
- public var isGalleryPresented: Binding<Bool>
- public var videoCaptureDevice: AVCaptureDevice?
- public var completion: (Result<ScanResult, ScanError>) -> Void
-
- public init(
- codeTypes: [AVMetadataObject.ObjectType],
- scanMode: ScanMode = .once,
- scanInterval: Double = 2.0,
- showViewfinder: Bool = false,
- simulatedData: String = "",
- shouldVibrateOnSuccess: Bool = true,
- isTorchOn: Bool = false,
- isGalleryPresented: Binding<Bool> = .constant(false),
- videoCaptureDevice: AVCaptureDevice? = AVCaptureDevice.default(for: .video),
- completion: @escaping (Result<ScanResult, ScanError>) -> Void
- ) {
- self.codeTypes = codeTypes
- self.scanMode = scanMode
- self.showViewfinder = showViewfinder
- self.scanInterval = scanInterval
- self.simulatedData = simulatedData
- self.shouldVibrateOnSuccess = shouldVibrateOnSuccess
- self.isTorchOn = isTorchOn
- self.isGalleryPresented = isGalleryPresented
- self.videoCaptureDevice = videoCaptureDevice
- self.completion = completion
- }
-
- public func makeCoordinator() -> ScannerCoordinator {
- ScannerCoordinator(parent: self)
- }
-
- public func makeUIViewController(context: Context) -> ScannerViewController {
- let viewController = ScannerViewController(showViewfinder: showViewfinder)
- viewController.delegate = context.coordinator
- return viewController
- }
-
- public func updateUIViewController(_ uiViewController: ScannerViewController, context: Context) {
- uiViewController.updateViewController(
- isTorchOn: isTorchOn,
- isGalleryPresented: isGalleryPresented.wrappedValue
- )
- }
-
-}
-
-@available(macCatalyst 14.0, *)
-struct CodeScannerView_Previews: PreviewProvider {
- static var previews: some View {
- CodeScannerView(codeTypes: [.qr]) { result in
- // do nothing
- }
- }
-}
diff --git a/damus/Views/CodeScanner/ScannerCoordinator.swift b/damus/Views/CodeScanner/ScannerCoordinator.swift
@@ -1,75 +0,0 @@
-//
-// CodeScanner.swift
-// https://github.com/twostraws/CodeScanner
-//
-// Created by Paul Hudson on 14/12/2021.
-// Copyright © 2021 Paul Hudson. All rights reserved.
-//
-
-import AVFoundation
-import SwiftUI
-
-extension CodeScannerView {
- @available(macCatalyst 14.0, *)
- public class ScannerCoordinator: NSObject, AVCaptureMetadataOutputObjectsDelegate {
- var parent: CodeScannerView
- var codesFound = Set<String>()
- var didFinishScanning = false
- var lastTime = Date(timeIntervalSince1970: 0)
-
- init(parent: CodeScannerView) {
- self.parent = parent
- }
-
- public func reset() {
- codesFound.removeAll()
- didFinishScanning = false
- lastTime = Date(timeIntervalSince1970: 0)
- }
-
- public func metadataOutput(_ output: AVCaptureMetadataOutput, didOutput metadataObjects: [AVMetadataObject], from connection: AVCaptureConnection) {
- if let metadataObject = metadataObjects.first {
- guard let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject else { return }
- guard let stringValue = readableObject.stringValue else { return }
- guard didFinishScanning == false else { return }
- let result = ScanResult(string: stringValue, type: readableObject.type)
-
- switch parent.scanMode {
- case .once:
- found(result)
- // make sure we only trigger scan once per use
- didFinishScanning = true
-
- case .oncePerCode:
- if !codesFound.contains(stringValue) {
- codesFound.insert(stringValue)
- found(result)
- }
-
- case .continuous:
- if isPastScanInterval() {
- found(result)
- }
- }
- }
- }
-
- func isPastScanInterval() -> Bool {
- Date().timeIntervalSince(lastTime) >= parent.scanInterval
- }
-
- func found(_ result: ScanResult) {
- lastTime = Date()
-
- if parent.shouldVibrateOnSuccess {
- AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
- }
-
- parent.completion(.success(result))
- }
-
- func didFail(reason: ScanError) {
- parent.completion(.failure(reason))
- }
- }
-}
diff --git a/damus/Views/CodeScanner/ScannerViewController.swift b/damus/Views/CodeScanner/ScannerViewController.swift
@@ -1,296 +0,0 @@
-//
-// CodeScanner.swift
-// https://github.com/twostraws/CodeScanner
-//
-// Created by Paul Hudson on 14/12/2021.
-// Copyright © 2021 Paul Hudson. All rights reserved.
-//
-
-import AVFoundation
-import UIKit
-
-extension CodeScannerView {
-
- @available(macCatalyst 14.0, *)
- public class ScannerViewController: UIViewController, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
-
- var delegate: ScannerCoordinator?
- private let showViewfinder: Bool
-
- private var isGalleryShowing: Bool = false {
- didSet {
- // Update binding
- if delegate?.parent.isGalleryPresented.wrappedValue != isGalleryShowing {
- delegate?.parent.isGalleryPresented.wrappedValue = isGalleryShowing
- }
- }
- }
-
- public init(showViewfinder: Bool = false) {
- self.showViewfinder = showViewfinder
- super.init(nibName: nil, bundle: nil)
- }
-
- required init?(coder: NSCoder) {
- self.showViewfinder = false
- super.init(coder: coder)
- }
-
- func openGallery() {
- isGalleryShowing = true
- let imagePicker = UIImagePickerController()
- imagePicker.delegate = self
- present(imagePicker, animated: true, completion: nil)
- }
-
- @objc func openGalleryFromButton(_ sender: UIButton) {
- openGallery()
- }
-
- public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
- isGalleryShowing = false
-
- if let qrcodeImg = info[.originalImage] as? UIImage {
- let detector = CIDetector(ofType: CIDetectorTypeQRCode, context: nil, options: [CIDetectorAccuracy: CIDetectorAccuracyHigh])!
- let ciImage = CIImage(image:qrcodeImg)!
- var qrCodeLink = ""
-
- let features = detector.features(in: ciImage)
-
- for feature in features as! [CIQRCodeFeature] {
- qrCodeLink += feature.messageString!
- }
-
- if qrCodeLink == "" {
- delegate?.didFail(reason: .badOutput)
- } else {
- let result = ScanResult(string: qrCodeLink, type: .qr)
- delegate?.found(result)
- }
- } else {
- print("Something went wrong")
- }
-
- dismiss(animated: true, completion: nil)
- }
-
- public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
- isGalleryShowing = false
- }
-
- #if targetEnvironment(simulator)
- override public func loadView() {
- view = UIView()
- view.isUserInteractionEnabled = true
-
- let label = UILabel()
- label.translatesAutoresizingMaskIntoConstraints = false
- label.numberOfLines = 0
- label.text = "You're running in the simulator, which means the camera isn't available. Tap anywhere to send back some simulated data."
- label.textAlignment = .center
-
- let button = UIButton()
- button.translatesAutoresizingMaskIntoConstraints = false
- button.setTitle("Select a custom image", for: .normal)
- button.setTitleColor(UIColor.systemBlue, for: .normal)
- button.setTitleColor(UIColor.gray, for: .highlighted)
- button.addTarget(self, action: #selector(openGalleryFromButton), for: .touchUpInside)
-
- let stackView = UIStackView()
- stackView.translatesAutoresizingMaskIntoConstraints = false
- stackView.axis = .vertical
- stackView.spacing = 50
- stackView.addArrangedSubview(label)
- stackView.addArrangedSubview(button)
-
- view.addSubview(stackView)
-
- NSLayoutConstraint.activate([
- button.heightAnchor.constraint(equalToConstant: 50),
- stackView.leadingAnchor.constraint(equalTo: view.layoutMarginsGuide.leadingAnchor),
- stackView.trailingAnchor.constraint(equalTo: view.layoutMarginsGuide.trailingAnchor),
- stackView.centerYAnchor.constraint(equalTo: view.centerYAnchor)
- ])
- }
-
- override public func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
- guard let simulatedData = delegate?.parent.simulatedData else {
- print("Simulated Data Not Provided!")
- return
- }
-
- // Send back their simulated data, as if it was one of the types they were scanning for
- let result = ScanResult(string: simulatedData, type: delegate?.parent.codeTypes.first ?? .qr)
- delegate?.found(result)
- }
-
- #else
-
- var captureSession: AVCaptureSession!
- var previewLayer: AVCaptureVideoPreviewLayer!
- let fallbackVideoCaptureDevice = AVCaptureDevice.default(for: .video)
-
- private lazy var viewFinder: UIImageView? = {
- guard let image = UIImage(named: "viewfinder", in: .main, with: nil) else {
- return nil
- }
-
- let imageView = UIImageView(image: image)
- imageView.translatesAutoresizingMaskIntoConstraints = false
- return imageView
- }()
-
- override public func viewDidLoad() {
- super.viewDidLoad()
-
- NotificationCenter.default.addObserver(self,
- selector: #selector(updateOrientation),
- name: Notification.Name("UIDeviceOrientationDidChangeNotification"),
- object: nil)
-
- view.backgroundColor = UIColor.black
- captureSession = AVCaptureSession()
-
- guard let videoCaptureDevice = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice else {
- return
- }
-
- let videoInput: AVCaptureDeviceInput
-
- do {
- videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
- } catch {
- delegate?.didFail(reason: .initError(error))
- return
- }
-
- if (captureSession.canAddInput(videoInput)) {
- captureSession.addInput(videoInput)
- } else {
- delegate?.didFail(reason: .badInput)
- return
- }
-
- let metadataOutput = AVCaptureMetadataOutput()
-
- if (captureSession.canAddOutput(metadataOutput)) {
- captureSession.addOutput(metadataOutput)
-
- metadataOutput.setMetadataObjectsDelegate(delegate, queue: DispatchQueue.main)
- metadataOutput.metadataObjectTypes = delegate?.parent.codeTypes
- } else {
- delegate?.didFail(reason: .badOutput)
- return
- }
-
- if previewLayer == nil {
- previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
- }
-
- previewLayer.frame = view.layer.bounds
- previewLayer.videoGravity = .resizeAspectFill
- view.layer.addSublayer(previewLayer)
- addviewfinder()
-
- delegate?.reset()
-
- if (captureSession?.isRunning == false) {
- DispatchQueue.global(qos: .userInitiated).async {
- self.captureSession.startRunning()
- }
- }
- }
-
- override public func viewWillLayoutSubviews() {
- previewLayer?.frame = view.layer.bounds
- }
-
- @objc func updateOrientation() {
- guard let orientation = view.window?.windowScene?.interfaceOrientation else { return }
- guard let connection = captureSession.connections.last, connection.isVideoOrientationSupported else { return }
- connection.videoOrientation = AVCaptureVideoOrientation(rawValue: orientation.rawValue) ?? .portrait
- }
-
- override public func viewDidAppear(_ animated: Bool) {
- super.viewDidAppear(animated)
- updateOrientation()
- }
-
- private func addviewfinder() {
- guard showViewfinder, let imageView = viewFinder else { return }
-
- view.addSubview(imageView)
-
- NSLayoutConstraint.activate([
- imageView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
- imageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
- imageView.widthAnchor.constraint(equalToConstant: 200),
- imageView.heightAnchor.constraint(equalToConstant: 200),
- ])
- }
-
- override public func viewDidDisappear(_ animated: Bool) {
- super.viewDidDisappear(animated)
-
- if (captureSession?.isRunning == true) {
- DispatchQueue.global(qos: .userInitiated).async {
- self.captureSession.stopRunning()
- }
- }
-
- NotificationCenter.default.removeObserver(self)
- }
-
- override public var prefersStatusBarHidden: Bool {
- true
- }
-
- override public var supportedInterfaceOrientations: UIInterfaceOrientationMask {
- .all
- }
-
- /** Touch the screen for autofocus */
- public override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
- guard touches.first?.view == view,
- let touchPoint = touches.first,
- let device = delegate?.parent.videoCaptureDevice ?? fallbackVideoCaptureDevice
- else { return }
-
- let videoView = view
- let screenSize = videoView!.bounds.size
- let xPoint = touchPoint.location(in: videoView).y / screenSize.height
- let yPoint = 1.0 - touchPoint.location(in: videoView).x / screenSize.width
- let focusPoint = CGPoint(x: xPoint, y: yPoint)
-
- do {
- try device.lockForConfiguration()
- } catch {
- return
- }
-
- // Focus to the correct point, make continiuous focus and exposure so the point stays sharp when moving the device closer
- device.focusPointOfInterest = focusPoint
- device.focusMode = .continuousAutoFocus
- device.exposurePointOfInterest = focusPoint
- device.exposureMode = AVCaptureDevice.ExposureMode.continuousAutoExposure
- device.unlockForConfiguration()
- }
-
- #endif
-
- func updateViewController(isTorchOn: Bool, isGalleryPresented: Bool) {
- if let backCamera = AVCaptureDevice.default(for: AVMediaType.video),
- backCamera.hasTorch
- {
- try? backCamera.lockForConfiguration()
- backCamera.torchMode = isTorchOn ? .on : .off
- backCamera.unlockForConfiguration()
- }
-
- if isGalleryPresented && !isGalleryShowing {
- openGallery()
- }
- }
-
- }
-}
diff --git a/damus/Views/LoginView.swift b/damus/Views/LoginView.swift
@@ -5,6 +5,7 @@
// Created by William Casarin on 2022-05-22.
//
+import CodeScanner
import SwiftUI
enum ParsedKey {
diff --git a/damus/Views/ProfileActionSheetView.swift b/damus/Views/ProfileActionSheetView.swift
@@ -19,9 +19,12 @@ struct ProfileActionSheetView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.presentationMode) var presentationMode
- init(damus_state: DamusState, pubkey: Pubkey) {
+ var navigationHandler: (() -> Void)?
+
+ init(damus_state: DamusState, pubkey: Pubkey, onNavigate navigationHandler: (() -> Void)? = nil) {
self.damus_state = damus_state
self._profile = StateObject(wrappedValue: ProfileModel(pubkey: pubkey, damus: damus_state))
+ self.navigationHandler = navigationHandler
}
func imageBorderColor() -> Color {
@@ -37,6 +40,12 @@ struct ProfileActionSheetView: View {
return self.profile_data()?.profile
}
+ func navigate(route: Route) {
+ damus_state.nav.push(route: route)
+ self.navigationHandler?()
+ dismiss()
+ }
+
var followButton: some View {
return ProfileActionSheetFollowButton(
target: .pubkey(self.profile.pubkey),
@@ -65,8 +74,7 @@ struct ProfileActionSheetView: View {
return VStack(alignment: .center, spacing: 10) {
Button(
action: {
- damus_state.nav.push(route: Route.DMChat(dms: dm_model))
- dismiss()
+ self.navigate(route: Route.DMChat(dms: dm_model))
},
label: {
Image("messages")
@@ -126,8 +134,7 @@ struct ProfileActionSheetView: View {
Button(
action: {
- damus_state.nav.push(route: Route.ProfileByKey(pubkey: profile.pubkey))
- dismiss()
+ self.navigate(route: Route.ProfileByKey(pubkey: profile.pubkey))
},
label: {
HStack {
diff --git a/damus/Views/QRCodeView.swift b/damus/Views/QRCodeView.swift
@@ -7,61 +7,16 @@
import SwiftUI
import CoreImage.CIFilterBuiltins
+import CodeScanner
-struct ProfileScanResult: Equatable {
- let pubkey: Pubkey
-
- init?(hex: String) {
- guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
- return nil
- }
-
- self.pubkey = pk
- }
-
- init?(string: String) {
- var str = string
- guard str.count != 0 else {
- return nil
- }
-
- if str.hasPrefix("nostr:") {
- str.removeFirst("nostr:".count)
- }
-
- if let decoded = hex_decode(str),
- str.count == 64
- {
- self.pubkey = Pubkey(Data(decoded))
- return
- }
-
- if str.starts(with: "npub"),
- let b32 = try? bech32_decode(str)
- {
- self.pubkey = Pubkey(b32.data)
- return
- }
-
- return nil
- }
-}
struct QRCodeView: View {
let damus_state: DamusState
@State var pubkey: Pubkey
- @Environment(\.presentationMode) var presentationMode
+ @Environment(\.dismiss) var dismiss
@State private var selectedTab = 0
- @State var scanResult: ProfileScanResult? = nil
- @State var profile: Profile? = nil
- @State var error: String? = nil
- @State private var outerTrimEnd: CGFloat = 0
-
- var animationDuration: Double = 0.5
-
- let generator = UIImpactFeedbackGenerator(style: .light)
@ViewBuilder
func navImage(systemImage: String) -> some View {
@@ -73,7 +28,7 @@ struct QRCodeView: View {
var navBackButton: some View {
Button {
- presentationMode.wrappedValue.dismiss()
+ dismiss()
} label: {
navImage(systemImage: "chevron.left")
}
@@ -98,7 +53,7 @@ struct QRCodeView: View {
TabView(selection: $selectedTab) {
QRView
.tag(0)
- QRCameraView()
+ self.qrCameraView
.tag(1)
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
@@ -182,22 +137,142 @@ struct QRCodeView: View {
}
}
- func QRCameraView() -> some View {
- return VStack(alignment: .center) {
+ var qrCameraView: some View {
+ QRCameraView(damusState: damus_state, bottomContent: {
+ Button(action: {
+ selectedTab = 0
+ }) {
+ HStack {
+ Text("View QR Code", comment: "Button to switch to view users QR Code")
+ .fontWeight(.semibold)
+ }
+ .frame(maxWidth: .infinity, maxHeight: 12, alignment: .center)
+ }
+ .buttonStyle(GradientButtonStyle())
+ .padding(50)
+ }, dismiss: dismiss)
+ }
+
+ func generateQRCode(pubkey: String) -> UIImage {
+ let data = pubkey.data(using: String.Encoding.ascii)
+ let qrFilter = CIFilter(name: "CIQRCodeGenerator")
+ qrFilter?.setValue(data, forKey: "inputMessage")
+ let qrImage = qrFilter?.outputImage
+
+ let colorInvertFilter = CIFilter(name: "CIColorInvert")
+ colorInvertFilter?.setValue(qrImage, forKey: "inputImage")
+ let outputInvertedImage = colorInvertFilter?.outputImage
+
+ let maskToAlphaFilter = CIFilter(name: "CIMaskToAlpha")
+ maskToAlphaFilter?.setValue(outputInvertedImage, forKey: "inputImage")
+ let outputCIImage = maskToAlphaFilter?.outputImage
+
+ let context = CIContext()
+ let cgImage = context.createCGImage(outputCIImage!, from: outputCIImage!.extent)!
+ return UIImage(cgImage: cgImage)
+ }
+}
+
+/// A view that scans for pubkeys/npub QR codes and displays a profile when needed.
+///
+/// ## Implementation notes:
+///
+/// - Marked as `fileprivate` since it is a relatively niche view, but can be made public with some adaptation if reuse is needed
+/// - 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
+fileprivate struct QRCameraView<Content: View>: View {
+
+ // MARK: Input parameters
+
+ var damusState: DamusState
+ /// A custom view to display on the bottom of the camera view
+ var bottomContent: () -> Content
+ var dismiss: DismissAction
+
+
+ // MARK: State properties
+
+ /// The main state of this view.
+ @State var scannerState: ScannerState = .scanning {
+ didSet {
+ switch (oldValue, scannerState) {
+ case (.scanning, .scanSuccessful), (.incompatibleQRCodeFound, .scanSuccessful):
+ generator.impactOccurred() // Haptic feedback upon a successful scan
+ default:
+ break
+ }
+ }
+ }
+
+
+ // MARK: Helper properties and objects
+
+ let generator = UIImpactFeedbackGenerator(style: .light)
+ /// A timer that ticks every second.
+ /// We need this to dismiss the incompatible QR code message automatically once the user is no longer pointing the camera at it
+ let timer = Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()
+
+ /// This is used to create a nice border animation when a scan is successful
+ ///
+ /// Computed property to simplify state management
+ var outerTrimEnd: CGFloat {
+ switch scannerState {
+ case .scanning, .error, .incompatibleQRCodeFound:
+ return 0.0
+ case .scanSuccessful:
+ return 1.0
+ }
+ }
+
+ /// A computed binding that indicates if there is an error to be displayed.
+ ///
+ /// This property is computed based on the main state `scannerState`, and is used to manage the error sheet without adding any extra state variables
+ var errorBinding: Binding<ScannerError?> {
+ Binding(
+ get: {
+ guard case .error(let error) = scannerState else { return nil }
+ return error
+ },
+ set: { newError in
+ guard let newError else {
+ self.scannerState = .scanning
+ return
+ }
+ self.scannerState = .error(newError)
+ })
+ }
+
+ /// A computed binding that indicates if there is a profile scan result to be displayed
+ ///
+ /// This property is computed based on the main state `scannerState`, and is used to manage the profile sheet without adding any extra state variables
+ var profileScanResultBinding: Binding<ProfileScanResult?> {
+ Binding(
+ get: {
+ guard case .scanSuccessful(result: let scanResult) = scannerState else { return nil }
+ return scanResult
+ },
+ set: { newProfileScanResult in
+ guard let newProfileScanResult else {
+ self.scannerState = .scanning
+ return
+ }
+ self.scannerState = .scanSuccessful(result: newProfileScanResult)
+ })
+ }
+
+
+ // MARK: View layouts
+
+ var body: some View {
+ VStack(alignment: .center) {
Text("Scan a user's pubkey", comment: "Text to prompt scanning a QR code of a user's pubkey to open their profile.")
.padding(.top, 50)
.font(.system(size: 24, weight: .heavy))
.foregroundColor(.white)
Spacer()
-
- CodeScannerView(codeTypes: [.qr], scanMode: .continuous, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
- switch result {
- case .success(let success):
- handleProfileScan(success.string)
- case .failure(let failure):
- self.error = failure.localizedDescription
- }
+
+ CodeScannerView(codeTypes: [.qr], scanMode: .continuous, scanInterval: 1, showViewfinder: true, simulatedData: "npub1k92qsr95jcumkpu6dffurkvwwycwa2euvx4fthv78ru7gqqz0nrs2ngfwd", shouldVibrateOnSuccess: false) { result in
+ self.handleNewProfileScanInfo(result)
}
.scaledToFit()
.frame(maxWidth: 300, maxHeight: 300)
@@ -209,96 +284,184 @@ struct QRCodeView: View {
Spacer()
+ self.hintMessage
+
Spacer()
- Button(action: {
- selectedTab = 0
- }) {
- HStack {
- Text("View QR Code", comment: "Button to switch to view users QR Code")
- .fontWeight(.semibold)
- }
- .frame( maxWidth: .infinity, maxHeight: 12, alignment: .center)
+ self.bottomContent()
+ }
+ // Show an error sheet if we are on an error state
+ .sheet(item: self.errorBinding, content: { error in
+ self.errorSheet(error: error)
+ })
+ // Show the profile sheet if we have successfully scanned
+ .sheet(item: self.profileScanResultBinding, content: { scanResult in
+ ProfileActionSheetView(damus_state: self.damusState, pubkey: scanResult.pubkey, onNavigate: {
+ dismiss()
+ })
+ .tint(DamusColors.adaptableBlack)
+ .presentationDetents([.large])
+ })
+ // Dismiss an incompatible QR code message automatically after a second or two of pointing it elsewhere.
+ .onReceive(timer) { _ in
+ switch self.scannerState {
+ case .incompatibleQRCodeFound(scannedAt: let date):
+ if abs(date.timeIntervalSinceNow) > 1.5 {
+ self.scannerState = .scanning
+ }
+ default:
+ break
}
- .buttonStyle(GradientButtonStyle())
- .padding(50)
}
}
- func handleProfileScan(_ scanned_str: String) {
- guard let result = ProfileScanResult(string: scanned_str) else {
- self.error = "Invalid profile QR"
- return
+ var hintMessage: some View {
+ HStack {
+ switch self.scannerState {
+ case .scanning:
+ Text("Point your camera to a QR code…", comment: "Text on QR code camera view instructing user to point to QR code")
+ case .incompatibleQRCodeFound:
+ 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")
+ case .scanSuccessful:
+ Text("Found profile!", comment: "Text on QR code camera view telling user that profile scan was successful.")
+ case .error:
+ Text("Error, please try again", comment: "Text on QR code camera view indicating an error")
+ }
}
-
- self.error = nil
-
- guard result != self.scanResult else {
- return
+ .foregroundColor(.white)
+ .padding()
+ }
+
+ func errorSheet(error: ScannerError) -> some View {
+ VStack(spacing: 10) {
+ Image(systemName: "exclamationmark.circle.fill")
+ Text("Error", comment: "Headline label for an error sheet on the QR code scanner")
+ .font(.headline)
+ Text(error.localizedDescription)
}
-
- generator.impactOccurred()
- cameraAnimate {
- scanResult = result
-
- find_event(state: damus_state, query: .profile(pubkey: result.pubkey)) { res in
- guard let res else {
- error = "Profile not found"
- return
+ .presentationDetents([.medium])
+ .tint(DamusColors.adaptableBlack)
+ }
+
+
+ // MARK: Scanning and state management logic
+
+ /// A base handler anytime the scanner sends new info,
+ ///
+ /// Behavior depends on the current state. In some states we completely ignore new scanner info (e.g. when looking at a profile)
+ /// This function mutates our state
+ func handleNewProfileScanInfo(_ scanInfo: Result<ScanResult, ScanError>) {
+ switch scannerState {
+ case .scanning, .incompatibleQRCodeFound:
+ withAnimation {
+ self.scannerState = self.processScanAndComputeNextState(scanInfo)
}
-
- switch res {
- case .invalid_profile:
- error = "Profile was found but was corrupt."
-
- case .profile:
- show_profile_after_delay()
-
- case .event:
- print("invalid search result")
+ case .scanSuccessful, .error:
+ return // We don't want new scan results to pop-up while in these states
+ }
+ }
+
+ /// Processes a QR code scan, and computes the next state to be applied to the view
+ func processScanAndComputeNextState(_ scanInfo: Result<ScanResult, ScanError>) -> ScannerState {
+ switch scanInfo {
+ case .success(let successfulScan):
+ guard let result = ProfileScanResult(string: successfulScan.string) else {
+ return .incompatibleQRCodeFound(scannedAt: Date.now)
}
-
- }
+ return .scanSuccessful(result: result)
+ case .failure(let error):
+ return .error(.scanError(error))
}
}
- func show_profile_after_delay() {
- DispatchQueue.main.asyncAfter(deadline: .now() + animationDuration) {
- if let scanResult {
- damus_state.nav.push(route: Route.ProfileByKey(pubkey: scanResult.pubkey))
- presentationMode.wrappedValue.dismiss()
+ // MARK: Helper types
+
+ /// A custom type for `QRCameraView` to track the state of the scanner.
+ ///
+ /// This is done to avoid having multiple independent variables to track the state, which increases the chance of state inconsistency.
+ /// 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.
+ enum ScannerState {
+ /// Camera is on and actively scanning new QR codes
+ case scanning
+ /// Scan and decoding was successful. Show profile.
+ case scanSuccessful(result: ProfileScanResult)
+ /// Tell the user they scanned a QR code that is incompatible
+ case incompatibleQRCodeFound(scannedAt: Date)
+ /// There was an error. Display a human readable and actionable message
+ case error(ScannerError)
+ }
+
+ /// Represents an error in this view, to be displayed to the user
+ ///
+ /// **Implementation notes:**
+ /// 1. This is identifiable because it that is needed for the error sheet view
+ /// 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.
+ enum ScannerError: Error, Identifiable {
+ case scanError(ScanError)
+
+ var localizedDescription: String {
+ switch self {
+ case .scanError(let scanError):
+ switch scanError {
+ case .badInput:
+ NSLocalizedString("The camera could not be accessed.", comment: "Camera's bad input error label")
+ case .badOutput:
+ NSLocalizedString("The camera was not capable of scanning the requested codes.", comment: "Camera's bad output error label")
+ case .initError(_):
+ NSLocalizedString("There was an unexpected error in initializing the camera.", comment: "Camera's initialization error label")
+ case .permissionDenied:
+ NSLocalizedString("Camera's permission was denied. You can change this in iOS settings.", comment: "Camera's permission denied error label")
+ }
}
}
+ var id: String { return self.localizedDescription }
}
+
+ /// A struct that holds results of a profile scan
+ struct ProfileScanResult: Equatable, Identifiable {
+ var id: Pubkey { return self.pubkey }
+ let pubkey: Pubkey
- func cameraAnimate(completion: @escaping () -> Void) {
- outerTrimEnd = 0.0
- withAnimation(.easeInOut(duration: animationDuration)) {
- outerTrimEnd = 1.05 // Set to 1.05 instead of 1.0 since sometimes `completion()` runs before the value reaches 1.0. This ensures the animation is done.
+ init?(hex: String) {
+ guard let pk = hex_decode(hex).map({ bytes in Pubkey(Data(bytes)) }) else {
+ return nil
+ }
+
+ self.pubkey = pk
}
- completion()
- }
-
- func generateQRCode(pubkey: String) -> UIImage {
- let data = pubkey.data(using: String.Encoding.ascii)
- let qrFilter = CIFilter(name: "CIQRCodeGenerator")
- qrFilter?.setValue(data, forKey: "inputMessage")
- let qrImage = qrFilter?.outputImage
- let colorInvertFilter = CIFilter(name: "CIColorInvert")
- colorInvertFilter?.setValue(qrImage, forKey: "inputImage")
- let outputInvertedImage = colorInvertFilter?.outputImage
-
- let maskToAlphaFilter = CIFilter(name: "CIMaskToAlpha")
- maskToAlphaFilter?.setValue(outputInvertedImage, forKey: "inputImage")
- let outputCIImage = maskToAlphaFilter?.outputImage
-
- let context = CIContext()
- let cgImage = context.createCGImage(outputCIImage!, from: outputCIImage!.extent)!
- return UIImage(cgImage: cgImage)
+ init?(string: String) {
+ var str = string.trimmingCharacters(in: ["\n", "\t", " "])
+ guard str.count != 0 else {
+ return nil
+ }
+
+ if str.hasPrefix("nostr:") {
+ str.removeFirst("nostr:".count)
+ }
+
+ if let decoded = hex_decode(str),
+ str.count == 64
+ {
+ self.pubkey = Pubkey(Data(decoded))
+ return
+ }
+
+ if str.starts(with: "npub"),
+ let b32 = try? bech32_decode(str)
+ {
+ self.pubkey = Pubkey(b32.data)
+ return
+ }
+
+ return nil
+ }
}
}
+
+// MARK: - Previews
+
struct QRCodeView_Previews: PreviewProvider {
static var previews: some View {
QRCodeView(damus_state: test_damus_state, pubkey: test_note.pubkey)
diff --git a/damus/Views/QRScanNSECView.swift b/damus/Views/QRScanNSECView.swift
@@ -5,6 +5,7 @@
// Created by Jericho Hasselbush on 9/29/23.
//
+import CodeScanner
import SwiftUI
import VisionKit
diff --git a/damus/Views/Wallet/NWCScannerView.swift b/damus/Views/Wallet/NWCScannerView.swift
@@ -6,6 +6,7 @@
//
import SwiftUI
+import CodeScanner
enum WalletScanResult: Equatable {
static func == (lhs: WalletScanResult, rhs: WalletScanResult) -> Bool {