Hey all,
I’m really hoping someone has some thoughts here. I’ve spent a week trying to solve this with Firebase documentation, google, CWC lessons, youtube videos and I’m just stuck. Essentially I’m creating a specialized todo list app. The view model retrieves the data, parses it and puts it into a published array and displays it. That works fine. You can then pull up the detail for any list (this screen isn’t complete yet but that’s irrelevant to the problem), or create a new list. Here’s where the problem comes. If I modify or delete an existing list, it seems as though the published property isn’t alerting the environment object and redrawing the top level view that shows the list of budgets. There are three pieces of data that should update for every list on that screen. The Name, the Icon and the color. The color actually appears to be properly updating but nothing else is. In addition, when you update an existing list, the detail for the list doesn’t update either.
IF you exit the app and restart it, then the data is properly displayed. I’ll paste code from the model, view model, and three views below. If someone wants to dig in further and view the actual project, send me your email and I’ll send you a link the the private github repo.
MODEL
import Foundation
struct Budget: Identifiable {
var id: String = ""
var ownerId: String = ""
var sharedWith = [String]()
var name: String = ""
var icon: String = ""
var iconColorName: String = "blue"
var budget: Double = 0.0
var listItems = [Item]()
var cartItems = [Item]()
}
VIEW MODEL
import SwiftUI
import Firebase
import FirebaseAuth
class BudgetModel: ObservableObject, Identifiable {
// Budget list
@Published var budgets = [Budget]()
// Firebase database reference
let db = Firestore.firestore()
init() {
getBudgetData()
}
// MARK: UI Methods
func iconColor(iconColorName: String) -> Color {
if iconColorName == "blue" {
return Constants.white
} else if iconColorName == "red" {
return .red
} else if iconColorName == "orange" {
return .orange
} else if iconColorName == "yellow" {
return .yellow
} else if iconColorName == "green" {
return .green
} else if iconColorName == "purple" {
return .purple
} else {
return Constants.white
}
}
// MARK: Firebase methods
func getBudgetData() {
var userBudgets = [Budget]()
// Make sure user is logged in
guard let currentUser = Auth.auth().currentUser else {
print("❌ No user logged in")
return
}
// Get a reference to all documents owned by the current user
let ref = db.collection("budgets").whereField("ownerId", isEqualTo: currentUser.uid)
ref.addSnapshotListener { [weak self] querySnapshot, error in
if let error = error {
print("❌ Failed to retrieve budgets with users ID: \(error)")
}
guard let snapshot = querySnapshot else {
print("❌ Error fetching snapshots: \(error!)")
return
}
snapshot.documentChanges.forEach { diff in
if (diff.type == .added) {
let newBudget = self?.parseBudget(diff.document)
if let newBudget = newBudget {
userBudgets.append(newBudget)
}
self?.budgets = userBudgets
}
if (diff.type == .modified) {
for (i, budget) in self!.budgets.enumerated() {
if budget.id == diff.document.documentID {
self?.budgets[i] = self!.parseBudget(diff.document)
break
}
}
print("🖕 Modified budget: \(diff.document.data())")
print("with IDt: \(diff.document.documentID)")
}
if (diff.type == .removed) {
for (i, budget) in self!.budgets.enumerated() {
if budget.id == diff.document.documentID {
print(self?.budgets.count)
self?.budgets.remove(at: i)
print(self?.budgets.count)
break
}
}
print("🖕 Removed budget: \(diff.document.data())")
}
}
}
}
func updateBudget(budgetId: String?, name: String, icon: String, iconColorName: String, listBudget: Double) {
guard let currentUser = Auth.auth().currentUser else {
print("❌ No user logged in")
return
}
let ownerId = currentUser.uid
// If budgetId is not nil access that document
let doc: DocumentReference
if let budgetId = budgetId {
doc = db.collection("budgets").document(budgetId)
} else {
doc = db.collection("budgets").document()
}
doc.setData(["ownerId":ownerId, "name":name, "icon":icon, "iconColorName":iconColorName, "budget":listBudget], merge: true) { error in
if let error = error {
print("❌ Failed to update budget: \(error)")
}
}
db.collection("users").document(currentUser.uid).collection("budgets").document(doc.documentID).setData(["date":Date()]) { error in
if let error = error {
print("❌ Could not add budget to user: \(error)")
}
self.getBudgetData()
}
}
func deleteBudget(_ budgetId: String) {
guard let currentUser = Auth.auth().currentUser else {
print("❌ No user logged in")
return
}
if budgetId == "" { return }
let doc = db.collection("budgets").document(budgetId)
doc.delete() { error in
if let error = error {
print("❌ Could not delete budget: \(error)")
}
}
}
func parseBudget(_ doc: QueryDocumentSnapshot) -> Budget {
var newBudget = Budget()
newBudget.id = doc.documentID
newBudget.name = doc["name"] as? String ?? ""
newBudget.ownerId = doc["ownerId"] as? String ?? ""
// TODO: Implement sharing
newBudget.sharedWith = [String]()
newBudget.icon = doc["icon"] as? String ?? "list.bullet.circle"
newBudget.iconColorName = doc["iconColorName"] as? String ?? "blue"
newBudget.budget = doc["budget"] as? Double ?? 0.0
newBudget.listItems = [Item]()
newBudget.cartItems = [Item]()
return newBudget
}
}
BUDGET LIST VIEW
import SwiftUI
struct Budgets: View {
// Data Models
@EnvironmentObject var budgetModel: BudgetModel
// State properites
@State var showingBudgetDetail = false
// Other Properties
var layout = [
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
NavigationView {
ZStack {
Constants.bgGradient
.ignoresSafeArea()
if budgetModel.budgets.count > 0 {
// MARK: Budgets exist
ScrollView(showsIndicators: false) {
LazyVGrid(columns: layout, spacing: 20) {
// Cycle through budgets array and create an icon for each
ForEach(budgetModel.budgets) { budget in
NavigationLink {
// Open budget detail for selected list and send index
BudgetDetail(budget: budget)
} label: {
BudgetListButton(
icon: budget.icon,
iconColorName: budget.iconColorName,
name: budget.name
)
}
}
.padding(.bottom, 20)
// Add new budgets
Button {
showingBudgetDetail = true
} label: {
BudgetListButton(icon: "plus", iconColorName: "blue", name: "New Budget")
}
.sheet(isPresented: $showingBudgetDetail) { } content: {
BudgetSetup()
// BudgetSetup(showingBudgetDetail: $showingBudgetDetail)
}
.padding(.bottom, 30)
}
.padding(.horizontal)
}
.navigationBarTitle("Budgets")
.navigationBarTitleDisplayMode(.inline)
.padding(.top, 30)
} else {
// MARK: Budgets do not exist
VStack {
Button {
showingBudgetDetail = true
} label: {
BudgetListButton(icon: "plus", iconColorName: "blue", name: "Oh no, this looks empty!\nLet's add your first list.", width: 120, height: 120)
}
.sheet(isPresented: $showingBudgetDetail) { } content: {
BudgetSetup()
}
}
}
}
}
}
}
BUDGET DETAIL VIEW
import SwiftUI
struct BudgetDetail: View {
// Environment
@EnvironmentObject var budgetModel: BudgetModel
@Environment(\.dismiss) var dismiss
// State Properties
@State var budgetListTab = Constants.BudgetDetailTab.list
@State var showingBudgetDetail = false
@State var budget: Budget
var body: some View {
VStack {
Picker(selection: $budgetListTab) {
Text(budget.name)
.tag(Constants.BudgetDetailTab.list)
Text("Cart")
.tag(Constants.BudgetDetailTab.cart)
} label: {
Text("Budget Detail Tab")
}
.pickerStyle(.segmented)
.padding(.horizontal)
if budgetListTab == .list {
BudgetDetailList(budget: budget)
} else if budgetListTab == .cart {
BudgetDetailCart(budget: budget)
}
Spacer()
}
.sheet(isPresented: $showingBudgetDetail) { } content: {
BudgetSetup(budget: budget)
}
.toolbar {
Button("Edit") {
showingBudgetDetail = true
}
}
}
}
BUDGET SETUP/MODIFY SHEET VIEW
import SwiftUI
struct BudgetSetup: View {
// Environment
@EnvironmentObject var budgetModel: BudgetModel
@Environment(\.presentationMode) var presentationMode
// Budget
@State var budget: Budget?
// State Properties
@State var iconColor: Color?
@State var iconColorName: String = "blue"
@State var nameText: String = ""
@State var budgetValue: Double?
@State var icon: String = "list.bullet"
@State var showingDeleteAlert = false
@FocusState var valueIsFocused: Bool
// Layout
let iconData = Bundle.main.decode([String].self, from: "symbols.json")
let layout = [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
]
var body: some View {
VStack {
// MARK: Title Bar
ZStack(alignment: .top) {
HStack {
Button {
presentationMode.wrappedValue.dismiss()
} label: {
Text("Cancel")
}
Spacer()
Button {
self.budgetModel.updateBudget(
budgetId: budget?.id,
name: nameText == "" ? "Budget" : nameText,
icon: icon,
iconColorName: iconColorName,
listBudget: budgetValue ?? 0.0)
presentationMode.wrappedValue.dismiss()
} label: {
Text("Done")
}
}
Text(budget?.name ?? "New Budget")
.bold()
.font(.title2)
}
.padding()
// MARK: Icon, Name and Value
Section {
VStack {
HStack {
Spacer()
ZStack {
Circle()
.foregroundColor(iconColor)
.frame(width: 120, height: 120)
.shadow(radius: 5)
Image(systemName: icon)
.resizable()
.scaledToFit()
.foregroundColor(Constants.white)
.frame(width: 60, height: 60)
}
Spacer()
}
.padding(.vertical, 40)
TextField("Budget Name", text: $nameText)
.textFieldStyle(.roundedBorder)
.font(.title2)
.padding(.bottom, 10)
CurrencyField("Amount to Budget", value: Binding(get: {
budgetValue
}, set: { number in
budgetValue = number
}))
.focused($valueIsFocused)
.padding(.bottom, 20)
}
}
.padding(.horizontal)
// MARK: Color Selector
Section {
LazyVGrid(columns: layout, alignment: .center, spacing: 20, content: {
Button {
iconColor = .red
iconColorName = "red"
} label: {
Circle()
.foregroundColor(.red)
.frame(width: 40, height: 40)
}
Button {
iconColor = .orange
iconColorName = "orange"
} label: {
Circle()
.foregroundColor(.orange)
.frame(width: 40, height: 40)
}
Button {
iconColor = .yellow
iconColorName = "yellow"
} label: {
Circle()
.foregroundColor(.yellow)
.frame(width: 40, height: 40)
}
Button {
iconColor = .green
iconColorName = "green"
} label: {
Circle()
.foregroundColor(.green)
.frame(width: 40, height: 40)
}
Button {
iconColor = Constants.blue
iconColorName = "blue"
} label: {
Circle()
.foregroundColor(.blue)
.frame(width: 40, height: 40)
}
Button {
iconColor = .purple
iconColorName = "purple"
} label: {
Circle()
.foregroundColor(.purple)
.frame(width: 40, height: 40)
}
})
}
.padding(.horizontal)
Divider()
.padding(.top, 20)
// MARK: Icon Selector
ScrollView(showsIndicators: false) {
Section {
LazyVGrid(columns: layout, alignment: .center, spacing: 20, content: {
ForEach(iconData, id: \.self) { icon in
Button {
self.icon = icon
} label: {
ZStack {
Circle()
.foregroundColor(Constants.lightGray)
.frame(width: 40, height: 40)
Image(systemName: icon)
.foregroundColor(Constants.darkGray)
.frame(width: 20, height: 20)
}
}
}
})
.padding(.vertical)
}
}
.padding(.horizontal)
// MARK: Delete Button
if budget != nil {
Button(action: {
showingDeleteAlert = true
}, label: {
Text("Delete Budget")
})
.buttonStyle(BudgeButton(buttonColor: .red, textColor: Constants.white))
.alert(isPresented: $showingDeleteAlert) {
Alert(
title: Text("Delete \(budget?.name ?? "Budget")"),
message: Text("Please tap Delete to confirm you would like to delete this budget."),
primaryButton: .default(
Text("Cancel")
),
secondaryButton: .destructive(
Text("DELETE")) {
budgetModel.deleteBudget(budget?.id ?? "")
presentationMode.wrappedValue.dismiss()
}
)
}
.padding()
}
}
.onTapGesture {
dismissKeyboard()
}
.onAppear() {
self.nameText = budget?.name ?? ""
self.icon = budget?.icon ?? "list.bullet"
self.iconColorName = budget?.iconColorName ?? "blue"
let iconColor = budgetModel.iconColor(iconColorName: self.iconColorName)
if iconColor == .white {
self.iconColor = Constants.blue
} else {
self.iconColor = iconColor
}
self.budgetValue = budget?.budget
print("TCB: Icon Color onAppear \(String(describing: self.iconColor))")
}
}
}
Anyone that may be able to help I owe you! Thanks for taking a look.