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.