0

I've been trying to implement a user class that is called by multiple view to handle various classes to enable exercise and achievement unlocking wherever a certain condition is met. My code currently publishes the xp to all the UI using from the user class, but any changes happening on exercise or achievement class don't get reflected on the multiples views until my app is reloaded with the saved data. How I can fix this issue, or what is the correct approach to these cases? Thanks! 

Note: The user class is passed as an Environment Object to all views.

User Class

import SwiftUI

@MainActor class User: ObservableObject {
    @Published var exercises: Exercises
    @Published var achievements: Achievements
    @Published private (set) var XP: Int
    
    // add last date logged
    let savePath = FileManager.documentsDirectory.appendingPathComponent(Keys.userXP)
     
    func checkIfEligibleForNewExercise() {
        if exercises.isDailyExerciseAlreadyDone {
            exercises.unlockNewExercise()
        }
    }
    
    func markAsDone(_ exercise: Exercise) {
        objectWillChange.send()
        XP += exercise.xp
        exercises.markAsDone(exercise)
        achievements.checkIfAnyAchievementIsAchievableWith(XP)
        checkIfEligibleForNewExercise()
        save()
    }
    
    init() {
        do {
            exercises = Exercises()
            achievements = Achievements()
            let data = try Data(contentsOf: savePath)
            XP = try JSONDecoder().decode(Int.self, from: data)
        } catch {
            XP = 0
            save()
        }
    }
    
    func save() {
        do {
            let encodedXP = try JSONEncoder().encode(XP)
            try encodedXP.write(to: savePath, options: [.atomicWrite, .completeFileProtection])
        } catch {
            fatalError("Error saving XP.")
        }
    }
}

Exercises Class

import SwiftUI
import Foundation

class Exercise: Codable, Equatable, Identifiable {
    var id = UUID()
    let name: String
    var isUnlocked: Bool
    var type: ExerciseType
    let description: String
    var timesDoneToday = 0
    var icon: String {
        name + " icon"
    }
    
    enum ExerciseType: Codable {
        case basic, advanced
    }
    
    var xp: Int {
        switch type {
        case .basic:
            return 5
        case .advanced:
            return 10
        }
    }
    
    init(id: UUID = UUID(), name: String, isUnlocked: Bool, type: ExerciseType, description: String) {
        self.id = id
        self.name = name
        self.isUnlocked = isUnlocked
        self.type = type
        self.description = description
    }
    
    
    static func == (lhs: Exercise, rhs: Exercise) -> Bool {
        lhs.id == rhs.id
    }
    
    static var example: Exercise {
        Exercise(name: "Test Exercise", isUnlocked: true, type: .basic, description: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Bibendum arcu vitae elementum curabitur vitae nunc sed. Sit amet cursus sit amet dictum sit. Habitant morbi tristique senectus et. Nunc consequat interdum varius sit amet mattis. Arcu cursus euismod quis viverra nibh. Nisl purus in mollis nunc sed id. Auctor urna nunc id cursus metus aliquam. Dolor purus non enim praesent elementum facilisis leo. Cras semper auctor neque vitae tempus quam pellentesque nec nam. Convallis tellus id interdum velit laoreet id donec. Sed viverra tellus in hac habitasse. Odio ut sem nulla pharetra diam sit amet nisl suscipit. Id ornare arcu odio ut sem nulla. Proin fermentum leo vel orci porta non pulvinar. Feugiat scelerisque varius morbi enim nunc faucibus a pellentesque. A arcu cursus vitae congue mauris rhoncus aenean vel. Nunc faucibus a pellentesque sit. Vel quam elementum pulvinar etiam. Est ultricies integer quis auctor elit sed. Lacus sed viverra tellus in hac. Mi ipsum faucibus vitae aliquet nec ullamcorper sit amet. Purus non enim praesent elementum facilisis leo vel. Vitae ultricies leo integer malesuada nunc vel risus commodo. Mollis aliquam ut porttitor leo a. Nisl vel pretium lectus quam id.")
    }
}

@MainActor class Exercises: ObservableObject {
    @Published private(set) var exercises: [Exercise]
    
    let savePath = FileManager.documentsDirectory.appendingPathComponent(Keys.exercises)
   
    init() {
         do {
             let data = try Data(contentsOf: savePath)
             exercises = try JSONDecoder().decode([Exercise].self, from: data)
         } catch {
             exercises = Exercises.fillExercises()
             save()
         }
     }
    
    var available: [Exercise] {
        exercises.filter { $0.isUnlocked }
    }
    
    var isDailyExerciseAlreadyDone: Bool {
        var exercisesDone = 0
        
        for exercise in exercises {
            if exercise.timesDoneToday != 0 {
                exercisesDone += exercise.timesDoneToday
            }
        }
        print("Exercises Done Today: \(exercisesDone)")
        return exercisesDone == 3 ? true : false
    }
       
    func markIsUnlocked(exercise: Exercise){
        guard let index = exercises.firstIndex(of: exercise) else {
            fatalError("Couldn't find the exercises")
        }
        
        objectWillChange.send()
        exercises[index].isUnlocked = true
    }
    
    func unlockNewExercise() {
         let exercisesLocked = exercises.filter {
             !$0.isUnlocked
        }
          
        if !exercisesLocked.isEmpty {
            markIsUnlocked(exercise: exercisesLocked.randomElement()!)
            print("Unlocked New exercise!")
        }
    }
    
    func markAsDone(_ exercise: Exercise) {
        guard let index = exercises.firstIndex(of: exercise) else {
            fatalError("Exercise not found")
        }
        
        print(exercises[index].timesDoneToday)
        objectWillChange.send()
        exercises[index].timesDoneToday += 1
        print(exercises[index].timesDoneToday)

        save()
    }
    
    func resetExercisesTimeDoneToday() {
        for exercise in exercises {
            exercise.timesDoneToday = 0
            print(exercise.timesDoneToday)
        }
        objectWillChange.send()
        save()
    }
    
    func save() {
        do {
            let encodedExercises = try JSONEncoder().encode(exercises)
            try encodedExercises.write(to: savePath, options: [.atomicWrite, .completeFileProtection])
        } catch {
            fatalError("Error saving Exercises")
        }
    }
}

Achievements Class

import SwiftUI

class Achievement: Codable, Equatable, Identifiable {
    
    var id = UUID()
    let name: String
    let description: String
    var isAchieved: Bool
    var dateAchieved: Date?
    let xpRequired: Int
    let type: AchievementType
    
    var symbol: String {
        switch type {
        case .journeyStarter:
            return "backpack"
            
        case .bronze, .silver, .gold:
            return "medal"
            
        case .diamond:
            return "diamond"
            
        case .sapphire:
            return "diamond.lefthalf.filled"
            
        case .platinum:
            return "diamond.inset.filled"
            
        }
    }
    
    var symbolColor: Color {
        switch type {
        case .journeyStarter:
            return .red
        case .bronze:
            return .brown
        case .silver:
            return .gray
        case .gold:
            return .yellow
        case .diamond, .sapphire, .platinum:
            return .cyan
        }
    }
    
    init(id: UUID = UUID(), name: String, description: String, isAchieved: Bool, dateAchieved: Date? = nil, xpRequired: Int, type: AchievementType) {
        self.id = id
        self.name = name
        self.description = description
        self.isAchieved = isAchieved
        self.dateAchieved = dateAchieved
        self.xpRequired = xpRequired
        self.type = type
    }
    
    enum AchievementType: Codable {
        case journeyStarter, bronze, silver, gold, diamond ,sapphire, platinum
    }
    
    static func == (lhs: Achievement, rhs: Achievement) -> Bool {
        lhs.id == rhs.id
    }
    
    var objDescription: String {
        return """
        id: \(id)
        name: \(name)
        description: \(description)
        isAchieved: \(isAchieved)
        """
    }
    
    static var example: Achievement {
        Achievement(name: "The Posture Checker", description: "This achievement is not real and only for testing.", isAchieved: false, xpRequired: 1000, type: .journeyStarter)
    }
}

@MainActor class Achievements: ObservableObject {
    @Published private(set) var achievements: [Achievement]
    
    let savePath = FileManager.documentsDirectory.appendingPathComponent(Keys.achievements)
    
    init() {
        do {
            let data = try Data(contentsOf: savePath)
            achievements = try JSONDecoder().decode([Achievement].self, from: data)
        } catch {
            achievements = Achievements.fillAchievements()
            save()
        }
    }
      
    func markAsAchieved(_ achievement: Achievement) {
        guard let index = achievements.firstIndex(of: achievement) else {
            fatalError("Couldn't find the achievement!")
        }
        
        objectWillChange.send()
        achievements[index].isAchieved = true
        achievements[index].dateAchieved = Date.now
        save()
    }
    
    func checkIfAnyAchievementIsAchievableWith(_ xp: Int) {
        for achievement in achievements {
            if achievement.xpRequired <= xp && !achievement.isAchieved {
                markAsAchieved(achievement)
            }
        }
    }
    
    func save() {
        do {
            let encodedAchievements = try JSONEncoder().encode(achievements)
            try encodedAchievements.write(to: savePath, options: [.atomicWrite, .completeFileProtection])
        } catch {
            fatalError("Error saving achievements")
        }
    }
}

New Code

The following code still uses Exercise and Achievement objects

import Foundation

@MainActor class ModelData: ObservableObject {
    @Published private (set) var xp: Int
    @Published private (set) var achievements: [Achievement]
    @Published private (set) var exercises: [Exercise]
    
    let xpSavePath = FileManager.documentsDirectory.appendingPathComponent(Keys.userXP)
    let achievementsSavePath = FileManager.documentsDirectory.appendingPathComponent(Keys.achievements)
    let exercisesSavePath = FileManager.documentsDirectory.appendingPathComponent(Keys.exercises)
    
    static let shared = ModelData()
    
    //MARK: Computed properties
    
    // Exercises Computed properties
    var isDailyExerciseAlreadyDone: Bool {
        var exercisesDone = 0
        
        for exercise in exercises {
            if exercise.timesDoneToday != 0 {
                exercisesDone += exercise.timesDoneToday
            }
        }
        print("Exercises Done Today: \(exercisesDone)")
        return exercisesDone == 3 ? true : false
    }
    
    var exercisesAvailable: [Exercise] {
        return exercises.filter { $0.isUnlocked }
    }
    
    init() {
        do {
            let decoder = JSONDecoder()
            
            let xpData = try Data(contentsOf: xpSavePath)
            let achievementsData = try Data(contentsOf: achievementsSavePath)
            let exercisesData = try Data(contentsOf: exercisesSavePath)
            
            xp = try decoder.decode(Int.self, from: xpData)
            achievements = try decoder.decode([Achievement].self, from: achievementsData)
            exercises = try decoder.decode([Exercise].self, from: exercisesData)
            print("Loaded data from disk successfully!")
        } catch {
            xp = 0
            achievements = Constants.initialAchievements
            exercises = Constants.initialExercises
            save()
        }
    }
    
    // MARK: Achievements methods
    func checkIfAnyAchievementIsAchievable() {
        for achievement in achievements {
            if achievement.xpRequired <= xp && !achievement.isAchieved {
                markAsAchieved(achievement)
            }
        }
    }
    
    func markAsAchieved(_ achievement: Achievement) {
        guard let index = achievements.firstIndex(of: achievement) else { fatalError("Couldn't find achievement: \(achievement.description)")}
        objectWillChange.send()
        achievements[index].isAchieved = true
        achievements[index].dateAchieved = Date.now
        print("Achievement named: \(achievements[index].name) unlocked.")
        save()
    }
    
    // MARK: Exercises methods
    func unlockNewExercise() {
        let exercisesLocked = exercises.filter { !$0.isUnlocked }
        
        if !exercises.isEmpty {
            markAsUnlocked(exercisesLocked.randomElement()!)
        }
    }
    
    func markAsUnlocked(_ exercise: Exercise) {
        guard let index = exercises.firstIndex(of: exercise) else { fatalError("Couldn't find exercise: \(exercises.description)")}
        
        objectWillChange.send()
        exercises[index].isUnlocked = true
        print("Exercise named: \(exercises[index].name) unlocked.")
        save()
    }
    
    func markAsDone(_ exercise: Exercise) {
        guard let index = exercises.firstIndex(of: exercise) else { fatalError("Couldn't find exercise: \(exercises.description)")}
        
        objectWillChange.send()
        xp += exercise.xp
        exercises[index].timesDoneToday += 1
        checkIfEligibleForNewExercise()
        checkIfAnyAchievementIsAchievable()
        save()
    }
    
    func checkIfEligibleForNewExercise() {
        if isDailyExerciseAlreadyDone {
            unlockNewExercise()
        }
    }
    
    func resetExercisesTimesDoneToday() {
        objectWillChange.send()
        
        for index in 0..<exercises.count {
            exercises[index].timesDoneToday = 0
        }
        
        save()
    }
    
    
    
    func save() {
        do {
            let encoder = JSONEncoder()
            let encodedXp = try encoder.encode(xp)
            let encodedAchievements = try encoder.encode(achievements)
            let encodedExercises = try encoder.encode(exercises)
            print("Successfully encoded model properties")
            
            try encodedXp.write(to: xpSavePath, options: [.atomicWrite, .completeFileProtection])
            try encodedAchievements.write(to: achievementsSavePath, options: [.atomicWrite, .completeFileProtection])
            try encodedExercises.write(to: exercisesSavePath, options: [.atomicWrite, .completeFileProtection])
            print("Successfully saved model properties")
        } catch {
            fatalError("Could not save model properties")
        }
    }
}
7
  • 1
    What are Exercises and Achievements? Nested @Published properties do not fire the publisher of a referring @Published property. Could you use @Published var exercises:[Exercise]? Commented Nov 17, 2022 at 21:24
  • Does this answer your question? stackoverflow.com/questions/65269802/… Commented Nov 17, 2022 at 21:26
  • @Paulw11 It actually can solve my issue, but if you see all my code each class has it own methods. If I do it that way the user class is responsible for making modifications directly instead other classes doing their work. Commented Nov 17, 2022 at 21:58
  • @synapticloop It might be worth checking It out. Still my code is arranged in a way that it will make it messy. SwiftUI watches each Published property, but in my code when that get nested on another class like the user I'm losing that core functionality. Commented Nov 17, 2022 at 22:05
  • 1
    This is where you can use an MVVM type concept. You have a view model (This is your Exercises class. When you instantiate the view model you pass it the instance of your model your User class. The view interacts with the view model. The view model is responsible for proxying requests to/from the model. The view model can use Combine to subscribe to changes in the model. Commented Nov 17, 2022 at 22:30

2 Answers 2

0

In Swift and SwiftUI we use structs for models, e.g.

struct User: Identifiable {

}

struct Achievement {
    var userID: UUID
}

We persist or sync them with a single store environment singleton object, e.g.

class Store: ObservableObject {

    @Published users: [User] = []
    @Published achievements: [Achievements] = []
  
    static var shared = Store()
    static var preview = Store(preview: true)

    init(preview: Bool) {
        if preview {
            // set test data
        }
    }

    func load(){}
    func save(){}

}

Hope that helps

Sign up to request clarification or add additional context in comments.

3 Comments

With your approach the object I need to pass to the environment is static let shared store?
With the approach suggested here you don't use environment object at all. its a shared singleton the swiftUI files can access just as viewControllers would access a singleton in UIKit. There are pros and cons to this approach, but for a user state or logged in user it could work.
The advantage of using environmentObject is you can use a different singleton for previewing that has sample data.
0

Singletons ARE NOT RECOMMENDED AND NOT BEST PRACTICE.

Passing data between multiple views in a view hierarchy is the specific use case for which @EnvironmentObject was created.

Note: @EnvironmentObject is neither a singleton, nor a global variable because it needs to be injected into a view hierarchy, and can only be called within that view hierarchy (Singletons and global variables would be available globally, including outside of view hierarchy).

Furthermore, you are breaking your code down too granularly, and making things observable that do not need to be (i.e. Excercise is a MODEL object that should be modeled as a struct. The ExcerciseList is what should be the ObservableObject, and will trigger a view update when either the model itself, or the list of models is updated).

Here is a basic code example to illustrate this structure:

import SwiftUI


struct Exercise: Codable {
    // model object properties here
}

class ExerciseList: ObservableObject {
    @Published var exercises: [Exercise] = [] // fill up array in init()
    
    
    // data fetching and formatting functions here
}

struct ContentView: View {
    @StateObject var exerciseList = ExerciseList()
    
    var body: some View {
        Text("Test")
            .environmentObject(exerciseList) 
// exercise list is now available to all child views of ContentView by calling @EnvironmentObject var exerciseList: ExerciseList (the var name doesn't matter)
        
    }
}

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.