1

I am attempting to make a small app where you can log your emotion for the day and plot it on a chart. I am making this using Swift Playgrounds in an effort to challenge myself with the constraints of the app, and it has been extremely challenging. There is no CoreData support, or any easy way to get it working manually, so I had to resort to just holding an array in UserDefaults and parsing through it (i know, super bad. this is never going to ever be used in production!). Unfortunately, this has left me with a problem. Calling the updateData() function in my DataManager class updates most of the UI properly, but not the charts with Swift Charts. Here is my code:

//Data.swift
import Foundation

struct Day: Hashable, Identifiable, Codable {
    var id: UUID
    var date: Date
    var emotion: Emotion
}

enum Emotion: Hashable, Codable {
    case ExtHappy
    case Happy
    case Good
    case Normal
    case Sad
    case Depressed
    case Angry
}

// my WWDC23 wishlist? proper core data implementation in Playgrounds(p.s. the code you're about to read is by far the most backwards way of doing this, but it is the easiest, it works fine, and it's so much better than doing all the CoreData code manually.)
class DataManager: ObservableObject {
    let storage = UserDefaults.standard
    @Published var data: [Day] = []
    init() {
        data = try! JSONDecoder().decode([Day].self, from: Data(storage.string(forKey: "data")?.utf8 ?? "[]".utf8))
    }

    func addEmotion(day: Day) {
        data.append(day)
        updateData()
    }

    func emotionIntToString(value: Int) -> String {
        switch value {
        case 6:
            return "Amazing"
        case 5:
            return "Happy"
        case 4:
            return "Good"
        case 3:
            return "Normal"
        case 2:
            return "Sad"
        case 1:
            return "Depressed"
        case 0:
            return "Angry"
        default:
            return "?"
        }
    }
    
    func dayIntToString(val: Int) -> String {
        if (val != -1 && val != 0) {
            return "\(-val) days"
        } else if (val == -1) {
            return "1 day"
        } else if (val == 0){
            return "Today"
        } else {
            return "?"
        }
    }

    func emotionToInt(emotion: Emotion) -> Int {
        switch emotion {
        case .ExtHappy:
            return 6
        case .Happy:
            return 5
        case .Good:
            return 4
        case .Normal:
            return 3
        case .Sad:
            return 2
        case .Depressed:
            return 1
        case .Angry:
            return 0
        }
    }

    func emotionToString(emotion: Emotion) -> String {
        switch emotion {
        case .ExtHappy:
            return "Extrememly Happy"
        case .Happy:
            return "Happy"
        case .Good:
            return "Good"
        case .Normal:
            return "Normal"
        case .Sad:
            return "Sad"
        case .Depressed:
            return "Depressed"
        case .Angry:
            return "Angry"
        }
    }

    func reset() {
        // dev only
        data = []
        updateData()
    }

    func getLatestDay() -> Day {
        if !data.isEmpty {
            var currentIterated = data[0]
            for day in data {
                if currentIterated.date.timeIntervalSince1970 - day.date.timeIntervalSince1970 > 0 {
                    currentIterated = day
                }
            }
            return currentIterated
        } else {
            // this will never be used!
            return Day(id: UUID(), date: Date.now, emotion: .Normal)
        }
    }

    // swift charts behaves in weird ways with dates. in my experience, it shows each minute on the x-axis (not what we want), so rather than deal with the poor documentation of Swift Charts, we convert each date to a numberic value representing how many days ago it was relative to the current date. -5 is about 5 days ago, -4 is 4 days ago, and so on. 0 is today.
    func getNumericValue(day: Day) -> Int {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.day]
        formatter.unitsStyle = .brief
        // it's easier this way. trust me
        return Int((formatter.string(from: day.date.timeIntervalSinceNow)?.replacingOccurrences(of: "day", with: "").replacingOccurrences(of: "days", with: "").replacingOccurrences(of: "s", with: ""))!)!
    }

    private func updateData() {
        objectWillChange.send()
        do {
            let data2 = try JSONEncoder().encode(data)
            if let string = String(data: data2, encoding: .utf8) {
                storage.set(string, forKey: "data")
            }
        } catch {
            print("Error encoding data: \(error.localizedDescription)")
        }
        data = try! JSONDecoder().decode([Day].self, from: Data(storage.string(forKey: "data")?.utf8 ?? "[]".utf8))
        print(data)
    }
}

//ChartView.swift
import Charts
import SwiftUI

struct ChartView: View {
    @StateObject var dataManager =  DataManager()
    @State var refresh = false
    var body: some View {
        Chart {
            ForEach(dataManager.data) { day in
                if (dataManager.getNumericValue(day: day) >= -5) {
                    LineMark(x:.value("Date", DataManager().getNumericValue(day: day)), y: .value("Emotion", DataManager().emotionToInt(emotion: day.emotion)))
                }
            }
        }.chartXAxis {
            AxisMarks(values: [-5, -4, -3, -2, -1, 0]) { val in
                AxisGridLine(centered: false)
                AxisValueLabel(dataManager.dayIntToString(val: val.as(Int.self) ?? 0))
            }
        }.chartYAxis {
            AxisMarks(values: [0, 1, 2, 3, 4, 5, 6]) { val in
                AxisValueLabel(dataManager.emotionIntToString(value: val.as(Int.self) ?? 0))
            }
        }
        
    }
}
//ContentView.swift
import SwiftUI
import Charts
import Combine

enum ChartOption {
    case bar
    case line
}

struct ContentView: View {
    //TODO: update ui on change & swift student challenge override mode
    @StateObject var dataManager = DataManager()
    @State var createSheet = false
    @State var chart: ChartOption = .bar
    @State var refresh = false
    var body: some View {
        NavigationStack {
            VStack {
                if (!dataManager.data.isEmpty && !refresh) {

                    TabView {
                        ChartView()
                            .padding(/*@START_MENU_TOKEN@*/.all/*@END_MENU_TOKEN@*/, 50).frame(height: 400)
                        ChartViewLine()
                            .padding(.all, 50).frame(height: 400)
                    }.tabViewStyle(.page(indexDisplayMode: .always)).indexViewStyle(.page(backgroundDisplayMode: .always))
                    Button {
                        dataManager.reset()
                    } label: {
                        Text("Reset")
                    }
                } else {
                    Text("Press the + to log your emotion for the day!")
                        .foregroundColor(Color.gray)
                }

            }.sheet(isPresented: $createSheet, content: {
                CreateDayModal().presentationDetents([PresentationDetent.medium])
            }).navigationTitle("Welcome").toolbar {
                ToolbarItem(placement: .navigationBarTrailing) {
                    Button {
                        createSheet.toggle()
                    } label: {
                        Label("Add", systemImage: "plus")
                    }

                }
            }
        }
    }
}

I have tried loads of extremely janky solutions, but to my surprise none of them worked. Namely:

.onChange(of: dataManager.data) { val in
dataManager = DataManager()
}

P.S.: I know most of this code is pretty shockingly bad. I am well aware of why it is bad, and have pledged to never ever ever ever do anything at all similar to this in an important, production environment. I am building this app for me, and me only, and truthfully only care that it works.

PaytonDEV
  • 63
  • 1
  • 6
  • You are creating multiple instances of `DataManager`. You should pass the instance from parent to child and use `@ObservedObject` in the child view. – jnpdx Apr 05 '23 at 23:52
  • Also, you can get rid of `refresh` -- it doesn't do anything – jnpdx Apr 05 '23 at 23:53
  • Currently you have two separate `dataManager`, that have no relations to each other. You should have only **one** `@StateObject var dataManager = DataManager()` in your `ContentView`, that you pass to your `ChartView` using for example, `ChartView(dataManager: dataManager)`. In `ChartView`, you should have `@ObservedObject var dataManager: DataManager`. Note you can also use `@EnvironmentObject var dataManager...` to pass it around. – workingdog support Ukraine Apr 05 '23 at 23:56
  • I really hope they make a “Swift Data” too – flopshot Apr 06 '23 at 00:02

0 Answers0