1

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")
        }
    }
}
Jack1886
  • 37
  • 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]`? – Paulw11 Nov 17 '22 at 21:24
  • Does this answer your question? https://stackoverflow.com/questions/65269802/published-property-not-updating-view-from-nested-view-model-swiftui – synapticloop Nov 17 '22 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. – Jack1886 Nov 17 '22 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. – Jack1886 Nov 17 '22 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. – Paulw11 Nov 17 '22 at 22:30
  • Does this answer your question? [SwiftUI View updating](https://stackoverflow.com/questions/68710726/swiftui-view-updating) – lorem ipsum Nov 17 '22 at 22:33
  • @Paulw11 I used your approach and made a single class containing published Exercise and Achievement array. Still having the same issues. Can you check my updated post (New Code) to check if I made something wrong in the class? What can be affecting my view to no react to changes made on the Datamodel? – Jack1886 Nov 19 '22 at 04:46

1 Answers1

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

malhal
  • 26,330
  • 7
  • 115
  • 133
  • With your approach the object I need to pass to the environment is static let shared store? – Jack1886 Nov 18 '22 at 14:43
  • 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. – MadeByDouglas Mar 23 '23 at 06:41
  • The advantage of using environmentObject is you can use a different singleton for previewing that has sample data. – malhal Mar 23 '23 at 06:45