Shakeology

Are you a cocktail lover tired of spending $15 to $20 per drink? Why not take control and get more for your money? Shakeology makes it easy to craft your own cocktails—even if you’ve never mixed one before!

My partner and I aren’t heavy drinkers, but we enjoy indulging in a couple of glasses now and then. At one point, I found myself thinking, “Is this really worth $15?” That’s when the idea struck: “I don’t know anything about mixing cocktails, but why not try making our own cocktails instead?”

This project came together in about 6 days, and I couldn’t be happier with the outcome. Creating this app was as enjoyable as mixing cocktails, and I’m thrilled to showcase it as a portfolio piece!

Main.


private func setUpVideoBackground() {
    let path = Bundle.main.path(forResource: "background1", ofType: "mp4")
    
    player = AVPlayer(url: URL(fileURLWithPath: path!))
    player!.actionAtItemEnd = AVPlayer.ActionAtItemEnd.none
    
    let playerLayer = AVPlayerLayer(player: player)
    
    playerLayer.frame = self.view.frame
    playerLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
    self.player?.isMuted = true
    self.view.layer.insertSublayer(playerLayer, at: 0)
    NotificationCenter.default.addObserver(self, selector: #selector(playerItemDidReachEnd), name: NSNotification.Name.AVPlayerItemDidPlayToEndTime, object: player!.currentItem)
    player!.seek(to: CMTime.zero)
    player!.play()
}

@objc func playerItemDidReachEnd() {
    player!.seek(to: CMTime.zero)
}

func configureLogoImageView() {
        
    logoImageView.translatesAutoresizingMaskIntoConstraints = false
    logoImageView.image         = Images.shakeLogo
    logoImageView.contentMode   = .scaleAspectFit
    
    NSLayoutConstraint.activate([
        
        logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 100),
        logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
        logoImageView.widthAnchor.constraint(equalToConstant: 300),
        logoImageView.heightAnchor.constraint(equalToConstant: 200)
    ])
}

@objc func pushCocktailListVC() {
    guard !isSearchButtonClicked else {
        presentCTAlertOnMainThread(title: "Something went wrong", message: "Something happened. Please try again later", buttonTitle: "OK")
        return
    }
    
    let cocktailListVC      = CocktailListVC()
    cocktailListVC.title    = "Searching for drinks"
    
    navigationController?.pushViewController(cocktailListVC, animated: true)
}

@objc func pushFavoriteListsVC() {
    let favoriteListVC      = FavoriteListVC()
    favoriteListVC.title    = "Favorite Drinks"
    navigationController?.pushViewController(favoriteListVC, animated: true)
}

Search Cocktail List.


func configureCollectionView() {    
    collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: UIHelper.createTwoColumnFlowLayout(in: view))
    view.addSubview(collectionView)
    
    collectionView.delegate = self
    collectionView.backgroundColor  = .systemBackground
    collectionView.register(CocktailCell.self, forCellWithReuseIdentifier: CocktailCell.reuseID)
}

func configureSearchController() {
    let searchController                                    = UISearchController()
    searchController.searchResultsUpdater                   = self
    searchController.searchBar.placeholder                  = "Search for cocktail..."
    searchController.obscuresBackgroundDuringPresentation   = false
    navigationItem.searchController                         = searchController
}

func displayCocktails() {
    showLoadingView()

    NetworkManager.shared.getCocktails() { [weak self] result in
        guard let self = self else { return }
        self.dismissLoadingView()
        
        switch result {
        case .success(let cocktails):
            self.updateUI(with: cocktails)
            
        case .failure(let error):
            self.presentCTAlertOnMainThread(title: "Bad Stuff Happened", message: error.rawValue, buttonTitle: "OK")
        }
    }
}

func updateUI(with cocktails: [Cocktail]) {
    self.cocktails.append(contentsOf: cocktails)
    self.updateData(on: self.cocktails)
}

func configureDataSource() {
    dataSource = UICollectionViewDiffableDataSource(collectionView: collectionView, cellProvider: { collectionView, indexPath, cocktail in
        let cell = collectionView.dequeueReusableCell(withReuseIdentifier: CocktailCell.reuseID, for: indexPath) as! CocktailCell
        cell.set(cocktail: cocktail)
        return cell
    })
}

func updateData(on cocktails: [Cocktail]) {
    var snapshot = NSDiffableDataSourceSnapshot()
    snapshot.appendSections([.main])
    snapshot.appendItems(cocktails)
    DispatchQueue.main.async { self.dataSource.apply(snapshot, animatingDifferences: true) }
}

extension CocktailListVC: UICollectionViewDelegate {
    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        
        let activeArray     = isSearching ? filteredCocktails : cocktails
        let cocktail        = activeArray[indexPath.item]
        let destVC          = DrinkInfoVC(drinkID:cocktail.idDrink)
        destVC.drinkID      = cocktail.idDrink
        let navController   = UINavigationController(rootViewController: destVC)
        
        present(navController, animated: true)
    }
}

extension CocktailListVC: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        
        guard let filter = searchController.searchBar.text, !filter.isEmpty else {
            filteredCocktails.removeAll()
            updateData(on: cocktails)
            isSearching = false
            return
        }
        
        isSearching = true
        filteredCocktails = cocktails.filter { $0.strDrink.lowercased().contains(filter.lowercased()) }
        updateData(on: filteredCocktails)
    }
}

Quick Fun Fact

After finishing the project, I took another look at the cocktail list and thought it looked a bit tacky compared to the other screens. A new design popped into my mind, and I quickly iterated the UI.

Technically, the design was done inside the code 😎.

Ingredients +
Instructions.


func configureScrollView() {    
    view.addSubview(scrollView)
    
    scrollView.addSubview(contentView)
    scrollView.pinToEdges(of: view)
    contentView.pinToEdges(of: scrollView)
    
    NSLayoutConstraint.activate([
        contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor),
        contentView.heightAnchor.constraint(equalToConstant: 1200)
    ])
}

func getDrinkInfo() {
    NetworkManager.shared.getDrinkInfo(for: drinkID) { [weak self] result in
        guard let self = self else { return }
        
        switch result {
        case .success(let cocktail):
            DispatchQueue.main.async { self.configureUIElements(with: cocktail) }
            
        case .failure(let error):
            self.presentCTAlertOnMainThread(title: "Something went wrong!!!", message: error.rawValue, buttonTitle: "OK")
        }
    }
}

func configureUIElements(with cocktail: DrinkInfo) {
    self.add(childVC: CTDrinkInfoMainImageVC(drink: cocktail), to: self.headerView)
    self.add(childVC: CTDrinkDescItemVC(drink: cocktail, delegate: self), to: self.itemViewOne)
}

func add(childVC: UIViewController, to containerView: UIView) {
    addChild(childVC)
    containerView.addSubview(childVC.view)
    childVC.view.frame = containerView.bounds
    childVC.didMove(toParent: self)
}

private func configureItems() {    
    descriptionInfoView.set(withDrink: drink.strDrink, withTag: drink.strTags ?? "", withCategory: drink.strCategory, withIBA: drink.strIBA ?? "")
    
    ingredientButton.set(backgroundColor: .clear, title: "INGREDIENTS", titleColor: .systemPink)
    instructionButton.set(backgroundColor: .clear, title: "INSTRUCTIONS", titleColor: .systemGray)
    ingredientInfoView.setIngredient(withDescription: configureIngredientInfo())
    instructionInfoView.setInstruction(withDescription: configureInstructionInfo())
}

func configureIngredientInfo() -> String {
    var ingredientString = ""
    
    ingredientString += "Prepare \(drink.strGlass)\n\n"
    for (ingredient, measurement) in drink.ingredients {
        ingredientString += "\u{2022}  \(measurement ?? "") \(ingredient)\n"
    }
    return ingredientString
}

func configureInstructionInfo() -> String {
    var instructionString   = ""
    let instructions        = drink.strInstructions
    
    instructionString += "Here is how you make \(drink.strDrink):\n\n"
    let sentences = instructions.components(separatedBy: ". ")
    for (index, sentence) in sentences.enumerated() {
        instructionString += "\(index + 1). \(sentence)\n\n"
    }
    instructionString += "\nEnjoy your \(drink.strDrink) cocktail!"
    
    return instructionString
}

override func ingredientButtonTapped() {
    delegate.didTapIngredientButton(for: drink)
    ingredientButton.set(backgroundColor: .clear, title: "INGREDIENTS", titleColor: .systemPink)
    instructionButton.set(backgroundColor: .clear, title: "INSTRUCTIONS", titleColor: .systemGray)
    ingredientInfoView.isHidden     = false
    instructionInfoView.isHidden    = true
}


override func instructionButtonTapped() {
    
    delegate.didTapInstructionButton(for: drink)
    instructionButton.set(backgroundColor: .clear, title: "INSTRUCTIONS", titleColor: .systemPink)
    ingredientButton.set(backgroundColor: .clear, title: "INGREDIENTS", titleColor: .systemGray)
    ingredientInfoView.isHidden     = true
    instructionInfoView.isHidden    = false
}
  
@objc func dismissVC() {
    if let navigationController = navigationController {
        if navigationController.viewControllers.count > 1 {
            navigationController.popViewController(animated: true)
        } else {
            dismiss(animated: true)
        }
    } else {
        dismiss(animated: true)
    }
}

Favorite List.


func configureTableView() {
        
    view.addSubview(tableView)
            
    tableView.frame         = view.bounds
    tableView.rowHeight     = 80
    tableView.delegate      = self
    tableView.dataSource    = self
    tableView.removeExcessCells()
    
    tableView.register(FavoriteCell.self, forCellReuseIdentifier: FavoriteCell.reuseID)
}

func getFavorites() {
    PersistenceManager.retrieveFavorites { [weak self] result in
        guard let self = self else { return }
        
        switch result {
        case .success(let favorites):
            self.updateUI(with: favorites)
            
        case .failure(let error):
            self.presentCTAlertOnMainThread(title: "Something went wrong", message: error.rawValue, buttonTitle: "OK")
        }
    }
}

func updateUI(with favorites: [Cocktail]) {    
    if favorites.isEmpty {
        self.showEmptyStateView(with: "No Favorites?\nAdd one on the Cocktail Screen.", in: self.view)
    } else {
        self.favorites = favorites
        DispatchQueue.main.async {
            self.tableView.reloadData()
            self.view.bringSubviewToFront(self.tableView)
        }
    }
}

extension FavoriteListVC: UITableViewDataSource, UITableViewDelegate {
    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        return favorites.count
    }
    
    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: FavoriteCell.reuseID) as! FavoriteCell
        let favorite = favorites[indexPath.row]
        
        cell.set(favorite: favorite)
        return cell
    }
    
    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        let favorite = favorites[indexPath.row]
        let destVC = DrinkInfoVC(drinkID: favorite.idDrink)
        
        navigationController?.pushViewController(destVC, animated: true)
    }
    
    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        guard editingStyle == .delete else { return }
        
        PersistenceManager.updateWith(favorite: favorites[indexPath.row], actionType: .remove) { [weak self] error in
            guard let self = self else { return }
            guard let error = error else {
                self.favorites.remove(at: indexPath.row)
                tableView.deleteRows(at: [indexPath], with: .left)
                return
            }
            self.presentCTAlertOnMainThread(title: "Unable to remove", message: error.rawValue, buttonTitle: "OK")
        }
    }
}

Empty Favorites.


func updateUI(with favorites: [Cocktail]) {  
    if favorites.isEmpty {
        self.showEmptyStateView(with: "No Favorites?\nAdd one on the Cocktail Screen.", in: self.view)
    } else {
        self.favorites = favorites
        DispatchQueue.main.async {
            self.tableView.reloadData()
            self.view.bringSubviewToFront(self.tableView)
        }
    }
}

func showEmptyStateView(with message: String, in view: UIView) {
    let emptyStateView = CTEmptyStateView(message: message)
    emptyStateView.frame = view.bounds
    view.addSubview(emptyStateView)
}

private func configureMessageLabel() {    
    messageLabel.numberOfLines  = 3
    messageLabel.textColor      = .secondaryLabel
    
    let labelCenterYConstant: CGFloat = DeviceTypes.isiPhoneSE || DeviceTypes.isiPhone8Zoomed ? -80 : -150
    
    NSLayoutConstraint.activate([
        
        messageLabel.centerYAnchor.constraint(equalTo: self.centerYAnchor, constant: labelCenterYConstant),
        messageLabel.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: 40),
        messageLabel.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -40),
        messageLabel.heightAnchor.constraint(equalToConstant: 200)
    ])
}