Play

Uncurrify

The world, in prices that feel familiar.

Built in 1 day

Menus, signs, receipts—just point and get the real cost in your currency. Stay immersed on your trip, with a clear view of your spending.

Backstory

In 2024, I traveled to Medellin, Colombia and fell in love the food, the people, the culture, but there was one problem.

Understanding the prices.

At first, converting it, wasn’t a big deal.

But surrounded by foreign prices, and having to do it every single time, it quickly became one.

That's why the first night at the hotel I thought:

What if currency didn't exist and every price just felt

…familiar?

…familiar?

With no coding experience "literally zero" I built it that night.

Make it yours

Take the code, push it further, and turn it into something amazing.

// Be warned, this is my first time "EVER" and I mean "EVEEER" coding something
import UIKit
import AVFoundation
import Vision

class CameraViewController: UIViewController, AVCaptureVideoDataOutputSampleBufferDelegate {
    private var captureSession: AVCaptureSession!
    private var previewLayer: AVCaptureVideoPreviewLayer!
    private var request: VNRecognizeTextRequest!
    private var rdContainerView: UIView!
    private var rdLabel: UILabel!
    private var copLabel: UILabel!
    private var editLabel: UILabel!
    private var rectangleGuideView: UIView!
    private var lastRecognizedNumber: Double?

    override func viewDidLoad() {
        super.viewDidLoad()
        
        setupCamera()
        setupVision()
        setupRectangleGuide()
        setupLabels()
        addTapGestureToCOPLabel()
        addPressGestureToRDLabel() // Add gesture recognizer to RD$ label
    }
    
    private func setupCamera() {
        captureSession = AVCaptureSession()
        guard let videoCaptureDevice = AVCaptureDevice.default(for: .video) else { return }
        let videoInput: AVCaptureDeviceInput
        
        do {
            videoInput = try AVCaptureDeviceInput(device: videoCaptureDevice)
        } catch {
            return
        }
        
        if captureSession.canAddInput(videoInput) {
            captureSession.addInput(videoInput)
        } else {
            return
        }
        
        let dataOutput = AVCaptureVideoDataOutput()
        dataOutput.setSampleBufferDelegate(self, queue: DispatchQueue(label: "videoQueue"))
        if captureSession.canAddOutput(dataOutput) {
            captureSession.addOutput(dataOutput)
        }
        
        previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.frame = view.layer.bounds
        previewLayer.videoGravity = .resizeAspectFill
        view.layer.addSublayer(previewLayer)
        
        DispatchQueue.global(qos: .userInitiated).async {
            self.captureSession.startRunning()
        }
    }
    
    private func setupVision() {
        request = VNRecognizeTextRequest(completionHandler: recognizeText)
        request.recognitionLevel = .accurate
    }
    
    private func setupRectangleGuide() {
        let guideHeight: CGFloat = 200.0 // Rectangle guide height
        
        // Rectangle guide view (border only)
        rectangleGuideView = UIView()
        rectangleGuideView.layer.borderColor = UIColor.white.cgColor
        rectangleGuideView.layer.borderWidth = 2.0
        rectangleGuideView.backgroundColor = UIColor.clear
        rectangleGuideView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(rectangleGuideView)
        
        NSLayoutConstraint.activate([
            rectangleGuideView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            rectangleGuideView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            rectangleGuideView.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            rectangleGuideView.heightAnchor.constraint(equalToConstant: guideHeight)
        ])
    }
    
    private func setupLabels() {
        // Container view for RD$ label to add padding
        rdContainerView = UIView()
        rdContainerView.backgroundColor = .black
        rdContainerView.layer.cornerRadius = 10
        rdContainerView.clipsToBounds = true
        rdContainerView.translatesAutoresizingMaskIntoConstraints = false
        
        // RD$ label inside the container
        rdLabel = UILabel()
        rdLabel.textColor = .white
        rdLabel.font = UIFont.boldSystemFont(ofSize: 48)
        rdLabel.textAlignment = .center
        rdLabel.translatesAutoresizingMaskIntoConstraints = false
        
        rdContainerView.addSubview(rdLabel)
        view.addSubview(rdContainerView)
        
        // COP label below the rectangle, outside of it
        copLabel = UILabel()
        copLabel.textColor = UIColor(red: 0.1, green: 0.3, blue: 0.8, alpha: 1.0)
        copLabel.font = UIFont.systemFont(ofSize: 24)
        copLabel.backgroundColor = UIColor(red: 0.7, green: 0.85, blue: 1.0, alpha: 1.0)
        copLabel.textAlignment = .center
        copLabel.layer.cornerRadius = 10
        copLabel.clipsToBounds = true
        copLabel.isUserInteractionEnabled = true
        copLabel.attributedText = NSAttributedString(
            string: "COP: 0.00", // Placeholder text
            attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue]
        )
        copLabel.translatesAutoresizingMaskIntoConstraints = false
        
        // Edit label below the COP label
        editLabel = UILabel()
        editLabel.text = "Editar"
        editLabel.textColor = .white
        editLabel.font = UIFont.systemFont(ofSize: 18)
        editLabel.textAlignment = .center
        editLabel.translatesAutoresizingMaskIntoConstraints = false
        
        view.addSubview(copLabel)
        view.addSubview(editLabel)
        
        NSLayoutConstraint.activate([
            // RD$ container view constraints
            rdContainerView.centerXAnchor.constraint(equalTo: rectangleGuideView.centerXAnchor),
            rdContainerView.centerYAnchor.constraint(equalTo: rectangleGuideView.centerYAnchor),
            rdContainerView.widthAnchor.constraint(lessThanOrEqualTo: rectangleGuideView.widthAnchor, multiplier: 0.9),
            
            // Padding constraints inside the container view
            rdLabel.topAnchor.constraint(equalTo: rdContainerView.topAnchor, constant: 8), // Top padding
            rdLabel.leadingAnchor.constraint(equalTo: rdContainerView.leadingAnchor, constant: 16), // Side padding
            rdLabel.trailingAnchor.constraint(equalTo: rdContainerView.trailingAnchor, constant: -16), // Side padding
            rdLabel.bottomAnchor.constraint(equalTo: rdContainerView.bottomAnchor, constant: -8), // Bottom padding
            
            // COP label constraints
            copLabel.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            copLabel.topAnchor.constraint(equalTo: rectangleGuideView.bottomAnchor, constant: 20),
            copLabel.widthAnchor.constraint(lessThanOrEqualTo: view.widthAnchor, multiplier: 0.9),
            
            // Edit label constraints
            editLabel.centerXAnchor.constraint(equalTo: copLabel.centerXAnchor),
            editLabel.topAnchor.constraint(equalTo: copLabel.bottomAnchor, constant: 5)
        ])
    }
    
    private func addTapGestureToCOPLabel() {
        let tapGesture = UITapGestureRecognizer(target: self, action: #selector(copLabelTapped))
        copLabel.addGestureRecognizer(tapGesture)
    }
    
    @objc private func copLabelTapped() {
        let alertController = UIAlertController(title: "Enter COP Value", message: nil, preferredStyle: .alert)
        alertController.addTextField { textField in
            textField.keyboardType = .decimalPad
            textField.placeholder = "Enter COP amount"
        }
        
        let confirmAction = UIAlertAction(title: "OK", style: .default) { [weak self] _ in
            if let textField = alertController.textFields?.first, let inputText = textField.text, let newCOPValue = Double(inputText) {
                self?.updateLabels(cop: newCOPValue, rd: newCOPValue / 69.70)
            }
        }
        
        let cancelAction = UIAlertAction(title: "Cancel", style: .cancel, handler: nil)
        
        alertController.addAction(confirmAction)
        alertController.addAction(cancelAction)
        
        present(alertController, animated: true, completion: nil)
    }
    
    private func addPressGestureToRDLabel() {
        let pressGesture = UILongPressGestureRecognizer(target: self, action: #selector(rdLabelPressed))
        rdLabel.addGestureRecognizer(pressGesture)
        rdLabel.isUserInteractionEnabled = true
    }
    
    @objc private func rdLabelPressed(_ gesture: UILongPressGestureRecognizer) {
        if gesture.state == .began {
            rdLabel.isHidden = true
        } else if gesture.state == .ended || gesture.state == .cancelled {
            rdLabel.isHidden = false
        }
    }
    
    private func recognizeText(request: VNRequest, error: Error?) {
        guard let observations = request.results as? [VNRecognizedTextObservation] else { return }
        
        for observation in observations {
            if let text = observation.topCandidates(1).first?.string {
                if let sanitizedNumber = sanitizeRecognizedText(text) {
                    let result = sanitizedNumber / 69.70
                    if sanitizedNumber != lastRecognizedNumber {
                        lastRecognizedNumber = sanitizedNumber
                        DispatchQueue.main.async {
                            self.updateLabels(cop: sanitizedNumber, rd: result)
                        }
                    }
                    break
                }
            }
        }
    }
    
    private func sanitizeRecognizedText(_ text: String) -> Double? {
        let sanitizedText = text.replacingOccurrences(of: " ", with: "")
        let regex = try! NSRegularExpression(pattern: "^\\d{1,3}(?:[.,]\\d{3})*(?:[.,]\\d+)?$")
        
        if regex.firstMatch(in: sanitizedText, options: [], range: NSRange(location: 0, length: sanitizedText.count)) != nil {
            var cleanedText = sanitizedText.replacingOccurrences(of: ".", with: "")
            cleanedText = cleanedText.replacingOccurrences(of: ",", with: "")
            if let lastSeparatorRange = cleanedText.range(of: "[.,](?=\\d+$)", options: .regularExpression) {
                cleanedText.replaceSubrange(lastSeparatorRange, with: ".")
            }
            return Double(cleanedText)
        }
        
        // Fallback return if regex match fails
        return Double(sanitizedText.replacingOccurrences(of: ",", with: "."))
    }
    
    private func updateLabels(cop: Double, rd: Double) {
        // Format the numbers with commas
        let formatter = NumberFormatter()
        formatter.numberStyle = .decimal
        formatter.maximumFractionDigits = 2
        
        let formattedCOP = formatter.string(from: NSNumber(value: cop)) ?? "\(cop)"
        let formattedRD = formatter.string(from: NSNumber(value: rd)) ?? "\(rd)"
        
        // Update RD$ label if RD$ is non-zero
        if rd > 0 {
            rdLabel.text = "RD$: \(formattedRD)"
            rdLabel.isHidden = false
        } else {
            rdLabel.isHidden = true
        }
        
        // Update COP label
        copLabel.attributedText = NSAttributedString(
            string: "COP: \(formattedCOP)",
            attributes: [.underlineStyle: NSUnderlineStyle.single.rawValue]
        )
        
        UIView.animate(withDuration: 0.5) {
            self.rdLabel.alpha = 1.0
            self.copLabel.alpha = 1.0
            self.editLabel.alpha = 1.0
        }
    }
}

// Extension for AVCaptureVideoDataOutputSampleBufferDelegate
extension CameraViewController {
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        
        // Calculate the cropping rectangle based on the rectangle guide
        let guideFrame = rectangleGuideView.frame
        let normalizedGuideRect = previewLayer.metadataOutputRectConverted(fromLayerRect: guideFrame)
        
        let requestHandler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, orientation: .up, options: [:])
        let croppedRequest = VNRecognizeTextRequest(completionHandler: recognizeText)
        croppedRequest.regionOfInterest = normalizedGuideRect
        
        try? requestHandler.perform([croppedRequest])
    }
}