LastPass clone - part 12: CoreData dive in

lastpass redesign Jun 01, 2020

Hey guys, in the previous part we created entities for password and note as well as their respective models and view models. I had to prepare those in a separate post as it would’ve made this article way too long. In this one we will start working with CoreData and do a little bit of refactoring.

Preparations

You should have the source code link in your email inbox if you are subscribed, otherwise click here to subscribe and get it.

CoreData Manager

We will put all core data related stuff in a separate file almost . In the service folder, add a swift file named CoreDataManager.swift containing the following code:

import Combine
import CoreData
import UIKit

class CoreDataManager: ObservableObject {
    
}

This will be our CoreData playground. Then add the following property to the top:

 private init(context: NSManagedObjectContext) {
        self.context = context
 }

We make the initialiser private, because we want to make this class a singleton meaning we will create a shared static function that will return an instance of this class containing a valid context.

Add the following code below context to create that shared instance:

    static let shared = CoreDataManager(context: (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext)

What we are doing here is initialising the shared instance with the context retrieved from the AppDelegate. If you open the AppDelegate.swift, and look for persistentContainer, you’ll find it declared as a lazy variable inside the class.

You will get an error saying the following Value of type 'CoreDataManager' has no member 'context’, it makes sense because we haven’t created the context property yet. Add the following to the top, above the init function.

var context: NSManagedObjectContext

Docs say:

A context consists of a group of related model objects that represent an internally consistent view of one or more persistent stores. Changes to managed objects are held in memory, in the associated context, until that context is saved to one or more persistent stores. A single managed object instance exists in one and only one context, but multiple copies of an object can exist in different contexts. Thus an object is unique to a particular context.

The above statement means that the context is an object that keep our local and persisted state of our data in sync. Every time we set one or more of our entities, the changes will only be persisted until we save the current state using the context.

Next, add the following method below the init:

    func save() -> Bool {
        if context.hasChanges {
            do {
                try context.save()
                return true
            } catch let error {
                print("Error saving changes to the context’s parent store.: \(error.localizedDescription)")
                return false
            }
        }
        return true
    }
    

In the above function, we first check if there are changes in the context, if it’s the case we call save() on the context to store those changes.

Entity Updates

Next, add the following function below the one above:

func updateLastUsedPassword(with id: UUID) -> Bool  {
        let request: NSFetchRequest<PasswordItem> = PasswordItem.fetchRequest() as! NSFetchRequest<PasswordItem>
        request.predicate = NSPredicate(format: "id = %@", id.uuidString)
        
        do {
            let results = try context.fetch(request)
            results[0].setValue(Date(), forKey: "lastUsed")
            return save()
            
        } catch let error {
            print(error)
        }
        
        return false
    }

This one will be called every time a user views a particular password’s details. What happening is that we use the password’s id to fetch the whole object from the database, update its lastUsed date to current date and save it again.

Next, add the following below:

 func setFavoritePassword(_ password: PasswordViewModel) -> Bool  {
        let request: NSFetchRequest<PasswordItem> = PasswordItem.fetchRequest() as! NSFetchRequest<PasswordItem>
        request.predicate = NSPredicate(format: "id = %@", password.id.uuidString)
        let isFavorite = password.isFavorite ? 0 : 1
        do {
            let results = try context.fetch(request)
            results[0].setValue(isFavorite, forKey: "isFavorite")
            return save()
            
        } catch let error {
            print(error)
        }
        
        return false
    }

This one will be called every time a user toggles the favourite button in the details view. What happening is that we use the password’s id to fetch the whole object from the database again, update its isFavorite attribute to 1 or 0 (true or false) and save it again.

Next, add the following 2 methods below the ones above:

    func updateLastUsedNote(with id: UUID) -> Bool  {
        let request: NSFetchRequest<NoteItem> = NoteItem.fetchRequest() as! NSFetchRequest<NoteItem>
        request.predicate = NSPredicate(format: "id = %@", id.uuidString)
        
        do {
            let results = try context.fetch(request)
            results[0].setValue(Date(), forKey: "lastUsed")
            return save()
            
        } catch let error {
            print(error)
        }
        
        return false
    }
    
    func setFavoriteNote(_ note: NoteViewModel) -> Bool  {
        let request: NSFetchRequest<PasswordItem> = PasswordItem.fetchRequest() as! NSFetchRequest<PasswordItem>
        request.predicate = NSPredicate(format: "id = %@", note.id.uuidString)
        
        let isFavorite = note.isFavorite ? 0 : 1
        
        do {
            let results = try context.fetch(request)
            results[0].setValue(isFavorite , forKey: "isFavorite")
            return save()
            
        } catch let error {
            print(error)
        }
        
        return false
    }
    

The above methods do the same as the ones we’ve created for the password, but these will be used to update the note.

Before creating the search function, let’s first add the following properties to the top of thee struct:

    @Published var notePredicate = NSPredicate(value: true)
    @Published var passwordPredicate = NSPredicate(value: true)
    @Published var sortDescriptor = NSSortDescriptor(key: "createdAt", ascending: false)
    
    @Published var searchTerm = ""
    
    @Published var showNotes = true
    @Published var showPasswords = true
    
    private var cancellableSet: Set<AnyCancellable> = []

Here is an explanation of what each of those properties will do:

  1. The first 2 predicates are the same, just for different entity. We will use the predicate to filter out the data based on a condition. We used predicates to retrieve item matching a particular id attribute.
  2. The sortDescriptor will be used to sort notes and password based on lastUsed when we want to see the most used passwords and notes. The default key is the createdAt atrribute which we will use to sort the data based on the creation date.
  3. The searchTerm is self-explanatory.
  4. The 2 boolean properties will be used to hide notes or passwords section depending on which filter is selected.
  5. The cancellableSet will hold the cancellable subscriptions to avoid premature completion of the search subscription

Now let’s implement the search process. Below everything, add the following block of code:

    func performSearch()  {
        let passwordSearchPublisher = self.$searchTerm.debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { term in
                
                return term.isEmpty ? NSPredicate.init(value: true) :  NSPredicate(format: "site CONTAINS[c] %@ || username CONTAINS[c] %@", term, term)
        }.eraseToAnyPublisher()
        
        let noteSearchPublisher = self.$searchTerm.debounce(for: 0.5, scheduler: RunLoop.main)
            .removeDuplicates()
            .map { term in
                return term.isEmpty ? NSPredicate.init(value: true) :   NSPredicate(format: "name CONTAINS[c] %@", term)
        }.eraseToAnyPublisher()
        
        Publishers.CombineLatest(passwordSearchPublisher, noteSearchPublisher)
            .receive(on: DispatchQueue.main)
            .sink {[unowned self] (passwordPred, notepred) in
                self.passwordPredicate = passwordPred
                self.notePredicate = notepred
        }.store(in: &self.cancellableSet)
    }

Here is what we are doing above:

  1. We create the passwordPublisher using the debounce on the search term. The publisher will be of type AnyPublisher<NSPredicate, Never>. As you can see the return statement in the map closure returns a predicate, for the password we filter based on the site name or username.
  2. We then create notePublisher which will be filtered on the name only. One could also include the content, but let’s just keep it simple.
  3. Using the CombineLatest publisher, we combine the 2 publishers and subscribe to the resulting publisher, and set those predicates we created earlier.

Last but not least in this class, we need created a function that will be used to apply filters. Below the function you’ve created, add the following:

    func applyFilter(_ filter: Filter){
        self.sortDescriptor = NSSortDescriptor(keyPath: \PasswordItem.createdAt, ascending: false)
        self.passwordPredicate = NSPredicate(value: true)
        self.notePredicate =  NSPredicate(value: true)
       
        switch filter {
        case .MostUsed:
            self.sortDescriptor = NSSortDescriptor(keyPath: \PasswordItem.lastUsed, ascending: false)
        case .Favorites:
            self.passwordPredicate = NSPredicate(format: "isFavorite = %@", "1")
            self.notePredicate = NSPredicate(format: "isFavorite = %@", "1")
        case .Notes:
            self.showPasswords = false
            self.showNotes = true
        case .Passwords:
            self.showNotes = false
            self.showPasswords = true
        case .AllItems:
            self.showNotes = true
            self.showPasswords = true
        }
    }

The above is pretty self-explanatory as well. We first reset the predicates and sortDescriptor to default values, then set corresponding values in the switch statements.

With that, we are done for this part. We are still missing the deletion functionality which is almost the same as the update. I’ll leave that to you, play with the project be creative, be liquid. In the next one we will integrate CoreData in views, stay tuned. Please feel free to share this article, subscribe if you haven’t done so already. If you have any question, send me an email. Happy coding.

John K

I am a software developer and code enthusiast. Do you want to work with me, have a suggestion or a request? Feel free to contact me at [email protected] or https://twitter.com/liquidcoder