From ec425642b4bf3f014072555686e56cc60221b3f1 Mon Sep 17 00:00:00 2001 From: William Brawner Date: Tue, 4 Jan 2022 20:51:38 -0600 Subject: [PATCH] Finish migration to async/await implementation --- Twigs.xcodeproj/project.pbxproj | 38 ++--- Twigs/AsyncData.swift | 6 + Twigs/Budget/BudgetDetailsView.swift | 28 ++-- Twigs/Budget/BudgetListsView.swift | 3 +- Twigs/Category/CategoryDataStore.swift | 15 -- Twigs/Category/CategoryDetailsView.swift | 77 +++++----- Twigs/Category/CategoryForm.swift | 62 ++++++++ Twigs/Category/CategoryFormSheet.swift | 92 +++--------- Twigs/Category/CategoryListDataStore.swift | 27 +++- Twigs/Category/CategoryListView.swift | 71 +++++---- Twigs/LoginView.swift | 12 +- Twigs/Network/TwigsInMemoryCacheService.swift | 8 +- .../RecurringTransactionDataStore.swift | 12 +- .../RecurringTransactionDetailsView.swift | 48 +++--- .../RecurringTransactionsListView.swift | 12 +- Twigs/SidebarBudgetView.swift | 15 +- Twigs/TabbedBudgetView.swift | 21 +-- Twigs/Transaction/AddTransactionView.swift | 102 ------------- Twigs/Transaction/EditTransactionForm.swift | 119 --------------- Twigs/Transaction/Transaction.swift | 14 +- Twigs/Transaction/TransactionDataStore.swift | 56 +++++-- Twigs/Transaction/TransactionDetails.swift | 70 +++++++++ .../Transaction/TransactionDetailsView.swift | 138 ++++++++++++------ Twigs/Transaction/TransactionEditView.swift | 96 ------------ Twigs/Transaction/TransactionForm.swift | 108 ++++++++++++++ Twigs/Transaction/TransactionFormSheet.swift | 107 ++++++++++++++ Twigs/Transaction/TransactionListView.swift | 83 +++++++---- Twigs/TwigsApp.swift | 17 ++- Twigs/User/AuthenticationDataStore.swift | 49 +++---- Twigs/Views/InlineLoadingView.swift | 6 +- Twigs/Views/MainView.swift | 26 ++-- 31 files changed, 807 insertions(+), 731 deletions(-) create mode 100644 Twigs/Category/CategoryForm.swift delete mode 100644 Twigs/Transaction/AddTransactionView.swift delete mode 100644 Twigs/Transaction/EditTransactionForm.swift create mode 100644 Twigs/Transaction/TransactionDetails.swift delete mode 100644 Twigs/Transaction/TransactionEditView.swift create mode 100644 Twigs/Transaction/TransactionForm.swift create mode 100644 Twigs/Transaction/TransactionFormSheet.swift diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index 44c60dc..6a75007 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -7,8 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 2821266023555FD300072D52 /* EditTransactionForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2821265F23555FD300072D52 /* EditTransactionForm.swift */; }; - 282126622357E45F00072D52 /* TransactionEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126612357E45F00072D52 /* TransactionEditView.swift */; }; + 2821266023555FD300072D52 /* TransactionFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2821265F23555FD300072D52 /* TransactionFormSheet.swift */; }; 282126A1235929B800072D52 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A0235929B800072D52 /* ProfileView.swift */; }; 282126A3235ABC1800072D52 /* TwigsInMemoryCacheService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */; }; 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */; }; @@ -18,7 +17,6 @@ 284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102312342E12F00EAFA29 /* CategoryListView.swift */; }; 2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2857EAEC233DA30B0026BC83 /* LoadingView.swift */; }; 289510242352AAFC00BC862B /* UserDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 289510232352AAFC00BC862B /* UserDataStore.swift */; }; - 28A1E95A235006A300CA57FE /* AddTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A1E959235006A300CA57FE /* AddTransactionView.swift */; }; 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94F1233C373900BFB70A /* LoginView.swift */; }; 28AC94F4233C373A00BFB70A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 28AC94F3233C373A00BFB70A /* Assets.xcassets */; }; 28AC94F7233C373A00BFB70A /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 28AC94F6233C373A00BFB70A /* Preview Assets.xcassets */; }; @@ -49,6 +47,9 @@ 802161D0277647920075761A /* AsyncObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802161CF277647920075761A /* AsyncObservableObject.swift */; }; 8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */; }; 8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */; }; + 8044BA3B2784B659009A78D4 /* TransactionDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3A2784B659009A78D4 /* TransactionDetails.swift */; }; + 8044BA3D2784CC0D009A78D4 /* TransactionForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */; }; + 8044BA3F27853054009A78D4 /* CategoryForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3E27853054009A78D4 /* CategoryForm.swift */; }; 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; }; 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; }; 8094A9C327567CAC006C6C62 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8094A9C227567CAC006C6C62 /* Collections */; }; @@ -74,8 +75,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 2821265F23555FD300072D52 /* EditTransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditTransactionForm.swift; sourceTree = ""; }; - 282126612357E45F00072D52 /* TransactionEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionEditView.swift; sourceTree = ""; }; + 2821265F23555FD300072D52 /* TransactionFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionFormSheet.swift; sourceTree = ""; }; 282126A0235929B800072D52 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsInMemoryCacheService.swift; sourceTree = ""; }; 282126A4235BCB7500072D52 /* Twigs.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Twigs.entitlements; sourceTree = ""; }; @@ -86,7 +86,6 @@ 284102312342E12F00EAFA29 /* CategoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListView.swift; sourceTree = ""; }; 2857EAEC233DA30B0026BC83 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; 289510232352AAFC00BC862B /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.swift; sourceTree = ""; }; - 28A1E959235006A300CA57FE /* AddTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTransactionView.swift; sourceTree = ""; }; 28AC94EA233C373900BFB70A /* Twigs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Twigs.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28AC94F1233C373900BFB70A /* LoginView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginView.swift; sourceTree = ""; }; 28AC94F3233C373A00BFB70A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; @@ -113,7 +112,6 @@ 28FE6B03234449DC00D5543E /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = ""; }; 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = ""; }; - 8005FD54277E61DC00E48B23 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; 8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; 800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = ""; }; 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = ""; }; @@ -123,6 +121,9 @@ 802161CF277647920075761A /* AsyncObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncObservableObject.swift; sourceTree = ""; }; 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = ""; }; 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = ""; }; + 8044BA3A2784B659009A78D4 /* TransactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetails.swift; sourceTree = ""; }; + 8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionForm.swift; sourceTree = ""; }; + 8044BA3E27853054009A78D4 /* CategoryForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryForm.swift; sourceTree = ""; }; 806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = ""; }; 80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = ""; }; 808582CD277E5E9E00006859 /* TwigsCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TwigsCore; path = ../TwigsCore; sourceTree = ""; }; @@ -186,6 +187,7 @@ 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */, 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */, 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */, + 8044BA3E27853054009A78D4 /* CategoryForm.swift */, ); path = Category; sourceTree = ""; @@ -205,7 +207,6 @@ isa = PBXGroup; children = ( 80DBED432774AE4F00CB0A88 /* Packages */, - 8005FD53277E61DC00E48B23 /* twigs-cli */, 28AC94EB233C373900BFB70A /* Products */, 28AC94EC233C373900BFB70A /* Twigs */, 28AC9503233C373A00BFB70A /* TwigsTests */, @@ -287,9 +288,9 @@ 28FE6B012344331B00D5543E /* TransactionDataStore.swift */, 28FE6B03234449DC00D5543E /* TransactionListView.swift */, 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */, - 28A1E959235006A300CA57FE /* AddTransactionView.swift */, - 2821265F23555FD300072D52 /* EditTransactionForm.swift */, - 282126612357E45F00072D52 /* TransactionEditView.swift */, + 2821265F23555FD300072D52 /* TransactionFormSheet.swift */, + 8044BA3A2784B659009A78D4 /* TransactionDetails.swift */, + 8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */, ); path = Transaction; sourceTree = ""; @@ -312,14 +313,6 @@ path = User; sourceTree = ""; }; - 8005FD53277E61DC00E48B23 /* twigs-cli */ = { - isa = PBXGroup; - children = ( - 8005FD54277E61DC00E48B23 /* main.swift */, - ); - path = "twigs-cli"; - sourceTree = ""; - }; 8005FD59277E623900E48B23 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -486,9 +479,8 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 28A1E95A235006A300CA57FE /* AddTransactionView.swift in Sources */, - 2821266023555FD300072D52 /* EditTransactionForm.swift in Sources */, - 282126622357E45F00072D52 /* TransactionEditView.swift in Sources */, + 2821266023555FD300072D52 /* TransactionFormSheet.swift in Sources */, + 8044BA3D2784CC0D009A78D4 /* TransactionForm.swift in Sources */, 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */, 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */, 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, @@ -505,6 +497,7 @@ 2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */, 282126A3235ABC1800072D52 /* TwigsInMemoryCacheService.swift in Sources */, 800DFC2C277FF47A00EDCE9B /* AsyncData.swift in Sources */, + 8044BA3B2784B659009A78D4 /* TransactionDetails.swift in Sources */, 28FE6AFA23441E3700D5543E /* CategoryListDataStore.swift in Sources */, 28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */, 284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */, @@ -517,6 +510,7 @@ 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */, + 8044BA3F27853054009A78D4 /* CategoryForm.swift in Sources */, 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, 8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */, diff --git a/Twigs/AsyncData.swift b/Twigs/AsyncData.swift index 262327b..914d346 100644 --- a/Twigs/AsyncData.swift +++ b/Twigs/AsyncData.swift @@ -13,6 +13,8 @@ enum AsyncData: Equatable where Data: Equatable { case empty case loading case error(Error, Data? = nil) + case editing(Data) + case saving(Data) case success(Data) static func == (lhs: AsyncData, rhs: AsyncData) -> Bool { @@ -24,6 +26,10 @@ enum AsyncData: Equatable where Data: Equatable { case (.error(let lError, let lData), .error(let rError, let rData)): return lError.localizedDescription == rError.localizedDescription && ((lData == nil && rData == nil) || lData == rData) + case (.editing(let lData), .editing(let rData)): + return lData == rData + case (.saving(let lData), .saving(let rData)): + return lData == rData case (.success(let lData), .success(let rData)): return lData == rData default: diff --git a/Twigs/Budget/BudgetDetailsView.swift b/Twigs/Budget/BudgetDetailsView.swift index e7ce0c7..3333517 100644 --- a/Twigs/Budget/BudgetDetailsView.swift +++ b/Twigs/Budget/BudgetDetailsView.swift @@ -12,27 +12,25 @@ import TwigsCore struct BudgetDetailsView: View { @EnvironmentObject var budgetDataStore: BudgetsDataStore let budget: Budget - + @ViewBuilder var body: some View { InlineLoadingView( data: self.$budgetDataStore.overview, action: { await self.budgetDataStore.loadOverview(self.budget) }, errorTextLocalizedStringKey: "budgets_load_failure" - ) { - if let overview = self.budgetDataStore.overview { - List { - Section(overview.budget.name) { - DescriptionOverview(overview: overview) - } - Section("income") { - IncomeOverview(overview: overview) - } - Section("expenses") { - ExpensesOverview(overview: overview) - } - }.listStyle(.insetGrouped) - } + ) { overview in + List { + Section(overview.budget.name) { + DescriptionOverview(overview: overview) + } + Section("income") { + IncomeOverview(overview: overview) + } + Section("expenses") { + ExpensesOverview(overview: overview) + } + }.listStyle(.insetGrouped) } } } diff --git a/Twigs/Budget/BudgetListsView.swift b/Twigs/Budget/BudgetListsView.swift index 4b4cf63..fbaf3a3 100644 --- a/Twigs/Budget/BudgetListsView.swift +++ b/Twigs/Budget/BudgetListsView.swift @@ -16,7 +16,8 @@ struct BudgetListsView: View { var body: some View { InlineLoadingView( - action: { return try await self.budgetDataStore.getBudgets(count: nil, page: nil) }, + data: $budgetDataStore.budgets, + action: { await self.budgetDataStore.getBudgets(count: nil, page: nil) }, errorTextLocalizedStringKey: "budgets_load_failure" ) { (budgets: [Budget]) in Section("budgets") { diff --git a/Twigs/Category/CategoryDataStore.swift b/Twigs/Category/CategoryDataStore.swift index 68c5194..29f6bee 100644 --- a/Twigs/Category/CategoryDataStore.swift +++ b/Twigs/Category/CategoryDataStore.swift @@ -27,19 +27,4 @@ class CategoryDataStore: ObservableObject { self.sum = .error(error) } } - - func save(_ category: TwigsCore.Category) async { - self.category = .loading - do { - var savedCategory: TwigsCore.Category - if category.id != "" { - savedCategory = try await self.categoryRepository.updateCategory(category) - } else { - savedCategory = try await self.categoryRepository.createCategory(category) - } - self.category = .success(savedCategory) - } catch { - self.category = .error(error, category) - } - } } diff --git a/Twigs/Category/CategoryDetailsView.swift b/Twigs/Category/CategoryDetailsView.swift index b079cc5..d588cfb 100644 --- a/Twigs/Category/CategoryDetailsView.swift +++ b/Twigs/Category/CategoryDetailsView.swift @@ -10,68 +10,63 @@ import SwiftUI import TwigsCore struct CategoryDetailsView: View { + @EnvironmentObject var categoryListDataStore: CategoryListDataStore + @EnvironmentObject var categoryDataStore: CategoryDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore + @EnvironmentObject var apiService: TwigsApiService let budget: Budget - let category: TwigsCore.Category @State var sum: Int? = 0 @State var editingCategory: Bool = false var spent: Int { get { - if let sum = self.sum { + if case let .success(sum) = categoryDataStore.sum { return abs(sum) } else { return 0 } } } - var remaining: Int { - get { - return category.amount - spent - } - } - var middleLabel: LocalizedStringKey { - get { - if category.expense { - return LocalizedStringKey("amount_spent") - } else { - return LocalizedStringKey("amount_earned") - } + func middleLabel(_ category: TwigsCore.Category) -> LocalizedStringKey { + if category.expense { + return LocalizedStringKey("amount_spent") + } else { + return LocalizedStringKey("amount_earned") } } var body: some View { - TransactionListView(self.budget, category: category) { - VStack { - Text(verbatim: category.description ?? "") - .padding() - HStack { - LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount) - LabeledCounter(title: middleLabel, amount: spent) - LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining) - } - }.frame(maxWidth: .infinity, alignment: .center) - } - - .onAppear { - Task { - try await self.sum = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil) + if let category = categoryListDataStore.selectedCategory { + TransactionListView(apiService: apiService, budget: budget, category: category) { + VStack { + Text(verbatim: category.description ?? "") + .padding() + HStack { + LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount) + LabeledCounter(title: middleLabel(category), amount: spent) + LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: category.amount - spent) + } + }.frame(maxWidth: .infinity, alignment: .center) + }.task { + await categoryDataStore.sum(categoryId: category.id) } - } - .navigationBarItems(trailing: Button(action: { + .navigationBarItems(trailing: Button(action: { self.editingCategory = true }) { Text("edit") - } - ) - .sheet(isPresented: self.$editingCategory, onDismiss: { - self.editingCategory = false - }, content: { - CategoryFormSheet(showSheet: self.$editingCategory, category: self.category, budgetId: self.category.budgetId) - }) + }) + .sheet(isPresented: self.$editingCategory, onDismiss: { + self.editingCategory = false + }, content: { + CategoryFormSheet(categoryForm: CategoryForm( + category: category, + categoryList: categoryListDataStore, + budgetId: category.budgetId + )) + }) + } } - init (_ category: TwigsCore.Category, budget: Budget) { - self.category = category + init (_ budget: Budget) { self.budget = budget } } @@ -90,7 +85,7 @@ struct LabeledCounter: View { #if DEBUG struct CategoryDetailsView_Previews: PreviewProvider { static var previews: some View { - CategoryDetailsView(MockCategoryRepository.category, budget: MockBudgetRepository.budget) + CategoryDetailsView(MockBudgetRepository.budget) .environmentObject(TransactionDataStore(MockTransactionRepository())) } } diff --git a/Twigs/Category/CategoryForm.swift b/Twigs/Category/CategoryForm.swift new file mode 100644 index 0000000..3d39475 --- /dev/null +++ b/Twigs/Category/CategoryForm.swift @@ -0,0 +1,62 @@ +// +// CategoryForm.swift +// Twigs +// +// Created by William Brawner on 1/4/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import Foundation +import TwigsCore + +class CategoryForm: ObservableObject { + let category: TwigsCore.Category? + let categoryList: CategoryListDataStore + let budgetId: String + let categoryId: String + @Published var title: String + @Published var description: String + @Published var amount: String + @Published var type: TransactionType + @Published var archived: Bool + let showDelete: Bool + + init( + category: TwigsCore.Category?, + categoryList: CategoryListDataStore, + budgetId: String + ) { + self.category = category + self.categoryList = categoryList + self.budgetId = budgetId + self.categoryId = category?.id ?? "" + self.showDelete = !self.categoryId.isEmpty + let baseCategory = category ?? TwigsCore.Category(budgetId: budgetId) + self.title = baseCategory.title + self.description = baseCategory.description ?? "" + self.amount = baseCategory.amountString + self.archived = baseCategory.archived + self.type = baseCategory.type + } + + + func save() async { + let amount = Int((Double(self.amount) ?? 0.0) * 100) + await categoryList.save(TwigsCore.Category( + budgetId: budgetId, + id: categoryId, + title: title, + description: description, + amount: amount, + expense: type.toBool(), + archived: archived + )) + } + + func delete() async { + guard let category = self.category else { + return + } + await categoryList.delete(category) + } +} diff --git a/Twigs/Category/CategoryFormSheet.swift b/Twigs/Category/CategoryFormSheet.swift index bbe84fe..7fa8629 100644 --- a/Twigs/Category/CategoryFormSheet.swift +++ b/Twigs/Category/CategoryFormSheet.swift @@ -10,53 +10,42 @@ import SwiftUI import TwigsCore struct CategoryFormSheet: View { - @EnvironmentObject var categoryDataStore: CategoryListDataStore - @State var loading: Bool = false - @Binding var showSheet: Bool - @State var title: String - @State var description: String - @State var amount: String - @State var type: TransactionType - @State var archived: Bool - let categoryId: String - let budgetId: String + @EnvironmentObject var categoryList: CategoryListDataStore + @ObservedObject var categoryForm: CategoryForm @State private var showingAlert = false @ViewBuilder var stateContent: some View { - if let _ = self.categoryDataStore.category { - EmbeddedLoadingView().onAppear { - self.showSheet = false - } - } else if self.loading { + switch categoryList.category { + case .success(_): + EmptyView() + case .saving(_): EmbeddedLoadingView() - } else { + default: Form { - TextField("prompt_name", text: self.$title) + TextField("prompt_name", text: $categoryForm.title) .textInputAutocapitalization(.words) - TextField("prompt_description", text: self.$description) + TextField("prompt_description", text: $categoryForm.description) .textInputAutocapitalization(.sentences ) - TextField("prompt_amount", text: self.$amount) + TextField("prompt_amount", text: $categoryForm.amount) .keyboardType(.decimalPad) - Picker("prompt_type", selection: self.$type) { + Picker("prompt_type", selection: $categoryForm.type) { ForEach(TransactionType.allCases) { type in Text(type.localizedKey) } } - Toggle("prompt_archived", isOn: self.$archived) - if categoryId != "" { + Toggle("prompt_archived", isOn: $categoryForm.archived) + if categoryForm.showDelete { Button(action: { self.showingAlert = true }) { Text("delete") .foregroundColor(.red) } - .alert(isPresented:$showingAlert) { + .alert(isPresented: $showingAlert) { Alert(title: Text("confirm_delete"), message: Text("cannot_undo"), primaryButton: .destructive(Text("delete"), action: { Task { - self.loading = true - try await self.categoryDataStore.delete(categoryId) - self.showSheet = false + await self.categoryForm.delete() } }), secondaryButton: .cancel()) } @@ -73,54 +62,21 @@ struct CategoryFormSheet: View { stateContent .navigationBarItems( leading: Button("cancel") { - self.showSheet = false + categoryList.cancelEdit() }, trailing: Button("save") { - let amount = Double(self.amount) ?? 0.0 Task { - try await self.categoryDataStore.save(Category( - budgetId: self.budgetId, - id: self.categoryId, - title: self.title, - description: self.description, - amount: Int(amount * 100.0), - expense: self.type == TransactionType.expense, - archived: false - )) + await self.categoryForm.save() } }) - }.onDisappear { - if categoryId.isEmpty { - self.categoryDataStore.clearSelectedCategory() - } - self.loading = false } } - - init(showSheet: Binding, category: TwigsCore.Category?, budgetId: String) { - let initialCategory = category ?? TwigsCore.Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false) - self._showSheet = showSheet - self._title = State(initialValue: initialCategory.title) - self._description = State(initialValue: initialCategory.description ?? "") - self._amount = State(initialValue: initialCategory.amount.toDecimalString()) - let type: TransactionType - if initialCategory.expense == false { - type = .income - } else { - type = .expense - } - self._type = State(initialValue: type) - self._archived = State(initialValue: initialCategory.archived) - self.categoryId = initialCategory.id - self.budgetId = budgetId - } } -#if DEBUG -struct CategoryFormSheet_Previews: PreviewProvider { - static var previews: some View { - CategoryFormSheet(showSheet: .constant(true), category: nil, budgetId: "") - .environmentObject(CategoryListDataStore(MockCategoryRepository())) - } -} -#endif +//#if DEBUG +//struct CategoryFormSheet_Previews: PreviewProvider { +// static var previews: some View { +// CategoryFormSheet(categoryForm: CategoryForm()) +// } +//} +//#endif diff --git a/Twigs/Category/CategoryListDataStore.swift b/Twigs/Category/CategoryListDataStore.swift index cde8287..4957699 100644 --- a/Twigs/Category/CategoryListDataStore.swift +++ b/Twigs/Category/CategoryListDataStore.swift @@ -13,7 +13,16 @@ import TwigsCore @MainActor class CategoryListDataStore: ObservableObject { @Published var categories: AsyncData<[TwigsCore.Category]> = .empty - @Published var category: AsyncData = .empty + @Published var category: AsyncData = .empty { + didSet { + if case let .success(category) = self.category { + self.selectedCategory = category + } else if case .empty = self.category { + self.selectedCategory = nil + } + } + } + @Published var selectedCategory: TwigsCore.Category? = nil func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = false, count: Int? = nil, page: Int? = nil) async { self.categories = .loading @@ -24,7 +33,7 @@ class CategoryListDataStore: ObservableObject { self.categories = .error(error) } } - + func save(_ category: TwigsCore.Category) async { self.category = .loading do { @@ -57,11 +66,19 @@ class CategoryListDataStore: ObservableObject { self.category = .error(error, category) } } - - func selectCategory(_ category: TwigsCore.Category) { - self.category = .success(category) + + func edit(_ category: TwigsCore.Category) async { + self.category = .editing(category) } + func cancelEdit() { + if let category = self.selectedCategory { + self.category = .success(category) + } else { + self.category = .empty + } + } + func clearSelectedCategory() { self.category = .empty } diff --git a/Twigs/Category/CategoryListView.swift b/Twigs/Category/CategoryListView.swift index aa44f36..573dfaf 100644 --- a/Twigs/Category/CategoryListView.swift +++ b/Twigs/Category/CategoryListView.swift @@ -12,25 +12,25 @@ import TwigsCore struct CategoryListView: View { @EnvironmentObject var categoryDataStore: CategoryListDataStore + @EnvironmentObject var apiService: TwigsApiService @State var requestId: String = "" - + @ViewBuilder var body: some View { InlineLoadingView( - action: { try await self.categoryDataStore.getCategories(budgetId: budget.id, expense: nil, archived: nil, count: nil, page: nil) }, + data: $categoryDataStore.categories, + action: { await self.categoryDataStore.getCategories(budgetId: budget.id, expense: nil, archived: nil, count: nil, page: nil) }, errorTextLocalizedStringKey: "Failed to load categories" - ) { - if let categories = self.categoryDataStore.categories { - List { - Section { - ForEach(categories.filter { !$0.archived }) { category in - CategoryListItemView(budget, category: category) - } + ) { categories in + List { + Section { + ForEach(categories.filter { !$0.archived }) { category in + CategoryListItemView(CategoryDataStore(transactionRepository: apiService), budget: budget, category: category) } - Section("Archived") { - ForEach(categories.filter { $0.archived }) { category in - CategoryListItemView(budget, category: category) - } + } + Section("Archived") { + ForEach(categories.filter { $0.archived }) { category in + CategoryListItemView(CategoryDataStore(transactionRepository: apiService), budget: budget, category: category) } } } @@ -46,9 +46,15 @@ struct CategoryListView: View { struct CategoryListItemView: View { let category: TwigsCore.Category let budget: Budget - @State var sum: Int? = nil - @EnvironmentObject var transactionDataStore: TransactionDataStore - + @EnvironmentObject var categoryListDataStore: CategoryListDataStore + @ObservedObject var categoryDataStore: CategoryDataStore + + init(_ categoryDataStore: CategoryDataStore, budget: Budget, category: TwigsCore.Category) { + self.categoryDataStore = categoryDataStore + self.budget = budget + self.category = category + } + var progressTintColor: Color { get { if category.expense { @@ -61,12 +67,14 @@ struct CategoryListItemView: View { var body: some View { NavigationLink( - destination: CategoryDetailsView(category, budget: self.budget) - .navigationBarTitle(category.title) - ) { - InlineLoadingView(action: { - self.sum = try await transactionDataStore.sum(categoryId: category.id) - }, errorTextLocalizedStringKey: "Failed to load category balance") { + tag: category, + selection: $categoryListDataStore.selectedCategory, + destination: { + CategoryDetailsView(self.budget) + .environmentObject(categoryDataStore) + .navigationBarTitle(categoryListDataStore.selectedCategory?.title ?? "") + }, + label: { VStack(alignment: .leading) { HStack { Text(verbatim: category.title) @@ -80,19 +88,15 @@ struct CategoryListItemView: View { .lineLimit(1) } progressView + }.task { + await categoryDataStore.sum(categoryId: category.id) } - - } - }.onAppear { - Task { - self.sum = try await transactionDataStore.sum(categoryId: category.id) - } - } + }) } var progressView: ProgressView { var balance: Float = 0.0 - if let sum = self.sum { + if case let .success(sum) = categoryDataStore.sum { balance = Float(abs(sum)) } return ProgressView(value: balance, maxValue: Float(category.amount), progressTintColor: progressTintColor, progressBarHeight: 4.0) @@ -101,7 +105,7 @@ struct CategoryListItemView: View { var remaining: Text { var remaining = "" var color = Color.primary - if let sum = self.sum { + if case let .success(sum) = categoryDataStore.sum { let amount = category.amount - abs(sum) if amount < 0 { remaining = abs(amount).toCurrencyString() + " over budget" @@ -116,11 +120,6 @@ struct CategoryListItemView: View { } return Text(verbatim: remaining).foregroundColor(color) } - - init (_ budget: Budget, category: TwigsCore.Category) { - self.budget = budget - self.category = category - } } diff --git a/Twigs/LoginView.swift b/Twigs/LoginView.swift index 2678c42..519ff44 100644 --- a/Twigs/LoginView.swift +++ b/Twigs/LoginView.swift @@ -14,10 +14,18 @@ struct LoginView: View { @State var username: String = "" @State var password: String = "" @EnvironmentObject var dataStore: AuthenticationDataStore + var loading: Bool { + switch dataStore.user { + case .loading: + return true + default: + return false + } + } var body: some View { LoadingView( - isShowing: $dataStore.loading, + isShowing: .constant(loading), loadingText: "loading_login" ) { NavigationView { @@ -36,7 +44,7 @@ struct LoginView: View { .textContentType(.password) Button("action_login", action: { Task { - try await self.dataStore.login(server: self.server, username: self.username, password: self.password) + await self.dataStore.login(server: self.server, username: self.username, password: self.password) } }).buttonStyle(DefaultButtonStyle()) Spacer() diff --git a/Twigs/Network/TwigsInMemoryCacheService.swift b/Twigs/Network/TwigsInMemoryCacheService.swift index 5282ea9..d4ca04b 100644 --- a/Twigs/Network/TwigsInMemoryCacheService.swift +++ b/Twigs/Network/TwigsInMemoryCacheService.swift @@ -6,18 +6,20 @@ // Copyright © 2019 William Brawner. All rights reserved. // +import SwiftUI import Foundation import TwigsCore class TwigsInMemoryCacheService: TwigsApiService { private var budgets = Set() private var categories = Set() - private var transactions = Set() + private var transactions = Set() + public init() { super.init(RequestHelper()) } - + // MARK: Budgets override func getBudgets(count: Int? = nil, page: Int? = nil) async throws -> [Budget] { let results = budgets.sorted { (first, second) -> Bool in @@ -133,6 +135,8 @@ class TwigsInMemoryCacheService: TwigsApiService { } } +extension TwigsApiService: ObservableObject {} + /** * Determines which slice of the array should be returned based on the count and page parameters */ diff --git a/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift index 70fc5d7..d9472e0 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift @@ -11,10 +11,20 @@ import Combine import Collections import TwigsCore +@MainActor class RecurringTransactionDataStore: AsyncObservableObject { private let repository: RecurringTransactionsRepository @Published var transactions: AsyncData<[RecurringTransaction]> = .empty - @Published var transaction: AsyncData = .empty + @Published var transaction: AsyncData = .empty { + didSet { + if case let .success(transaction) = self.transaction { + self.selectedTransaction = transaction + } else if case .empty = transaction { + self.selectedTransaction = nil + } + } + } + @Published var selectedTransaction: RecurringTransaction? = nil init(_ repository: RecurringTransactionsRepository) { self.repository = repository diff --git a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift index 8fce7ca..8db05a7 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift @@ -10,40 +10,38 @@ import SwiftUI import TwigsCore struct RecurringTransactionDetailsView: View { - let transaction: RecurringTransaction + @EnvironmentObject var dataStore: RecurringTransactionDataStore var body: some View { - ScrollView { - VStack(alignment: .leading) { - Text(transaction.title) - .font(.title) - Text(transaction.amount.toCurrencyString()) - .font(.headline) - .foregroundColor(transaction.expense ? .red : .green) - .multilineTextAlignment(.trailing) - if let description = transaction.description { - Text(description) - } - Text(transaction.frequency.naturalDescription) - Spacer().frame(height: 10) - LabeledField(label: "start", value: transaction.start.toLocaleString(), showDivider: true) - LabeledField(label: "end", value: transaction.end?.toLocaleString(), showDivider: true) - CategoryLineItem(transaction.categoryId) - BudgetLineItem() - UserLineItem(transaction.createdBy) - }.padding() + if let transaction = dataStore.selectedTransaction { + ScrollView { + VStack(alignment: .leading) { + Text(transaction.title) + .font(.title) + Text(transaction.amount.toCurrencyString()) + .font(.headline) + .foregroundColor(transaction.expense ? .red : .green) + .multilineTextAlignment(.trailing) + if let description = transaction.description { + Text(description) + } + Text(transaction.frequency.naturalDescription) + Spacer().frame(height: 10) + LabeledField(label: "start", value: transaction.start.toLocaleString(), loading: .constant(false), showDivider: true) + LabeledField(label: "end", value: transaction.end?.toLocaleString(), loading: .constant(false), showDivider: true) +// CategoryLineItem() +// BudgetLineItem() +// UserLineItem() + }.padding() + } } } - - init(_ transaction: RecurringTransaction) { - self.transaction = transaction - } } #if DEBUG struct RecurringTransactionDetailsView_Previews: PreviewProvider { static var previews: some View { - RecurringTransactionDetailsView(MockRecurringTransactionRepository.transaction) + RecurringTransactionDetailsView() } } #endif diff --git a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift index 51d5620..a64de90 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift @@ -15,9 +15,8 @@ struct RecurringTransactionsListView: View { var body: some View { InlineLoadingView( - action: { - return try await self.dataStore.getRecurringTransactions(self.budget.id) - }, + data: $dataStore.transactions, + action: { await self.dataStore.getRecurringTransactions(self.budget.id) }, errorTextLocalizedStringKey: "Failed to load recurring transactions" ) { (transactions: [RecurringTransaction]) in List { @@ -38,6 +37,7 @@ struct RecurringTransactionView_Previews: PreviewProvider { #endif struct RecurringTransactionsListItemView: View { + @EnvironmentObject var dataStore: RecurringTransactionDataStore let transaction: RecurringTransaction init (_ transaction: RecurringTransaction) { @@ -46,8 +46,12 @@ struct RecurringTransactionsListItemView: View { var body: some View { NavigationLink( - destination: RecurringTransactionDetailsView(transaction) + tag: transaction, + selection: $dataStore.selectedTransaction, + destination: { + RecurringTransactionDetailsView() .navigationBarTitle("details", displayMode: .inline) + } ) { HStack { VStack(alignment: .leading) { diff --git a/Twigs/SidebarBudgetView.swift b/Twigs/SidebarBudgetView.swift index 3193415..8133cec 100644 --- a/Twigs/SidebarBudgetView.swift +++ b/Twigs/SidebarBudgetView.swift @@ -12,10 +12,9 @@ import TwigsCore struct SidebarBudgetView: View { @EnvironmentObject var authenticationDataStore: AuthenticationDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore - let apiService: TwigsApiService + @EnvironmentObject var apiService: TwigsApiService @State var isSelectingBudget = true @State var hasSelectedBudget = false - @State var isAddingTransaction = false @State var tabSelection: Int? = 0 @ViewBuilder @@ -34,7 +33,7 @@ struct SidebarBudgetView: View { NavigationLink( tag: 1, selection: $tabSelection, - destination: { TransactionListView(budget).navigationBarTitle("transactions") }, + destination: { TransactionListView(apiService: apiService, budget: budget).navigationBarTitle("transactions") }, label: { Label("transactions", systemImage: "dollarsign.circle") }) .keyboardShortcut("2") NavigationLink( @@ -53,10 +52,6 @@ struct SidebarBudgetView: View { } .navigationTitle(budget.name) }.navigationViewStyle(.columns) - .environmentObject(TransactionDataStore(apiService)) - .environmentObject(CategoryListDataStore(apiService)) - .environmentObject(budgetDataStore) - .environmentObject(UserDataStore(apiService)) } else { ActivityIndicator(isAnimating: .constant(true), style: .large) } @@ -80,6 +75,12 @@ struct SidebarBudgetView: View { } } }) + .sheet(isPresented: $budgetDataStore.showBudgetSelection, + content: { + List { + BudgetListsView().environmentObject(budgetDataStore) + } + }) .interactiveDismissDisabled(true) } } diff --git a/Twigs/TabbedBudgetView.swift b/Twigs/TabbedBudgetView.swift index 9f1f7c2..029faf2 100644 --- a/Twigs/TabbedBudgetView.swift +++ b/Twigs/TabbedBudgetView.swift @@ -10,13 +10,9 @@ import SwiftUI import TwigsCore struct TabbedBudgetView: View { - @EnvironmentObject var authDataStore: AuthenticationDataStore @EnvironmentObject var authenticationDataStore: AuthenticationDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore - let apiService: TwigsApiService - @State var isSelectingBudget = true - @State var hasSelectedBudget = false - @State var isAddingTransaction = false + @EnvironmentObject var apiService: TwigsInMemoryCacheService @State var tabSelection: Int = 0 @ViewBuilder @@ -39,15 +35,7 @@ struct TabbedBudgetView: View { .tag(0) .keyboardShortcut("1") NavigationView { - TransactionListView(budget) - .sheet(isPresented: $isAddingTransaction, - onDismiss: { - isAddingTransaction = false - }, - content: { - AddTransactionView(showSheet: self.$isAddingTransaction, budgetId: budget.id) - .navigationBarTitle("add_transaction") - }) + TransactionListView(apiService: apiService, budget: budget) .navigationBarTitle("transactions") } .tabItem { @@ -76,10 +64,7 @@ struct TabbedBudgetView: View { } .tag(3) .keyboardShortcut("4") - }.environmentObject(TransactionDataStore(apiService)) - .environmentObject(CategoryListDataStore(apiService)) - .environmentObject(budgetDataStore) - .environmentObject(UserDataStore(apiService)) + } } else { ActivityIndicator(isAnimating: .constant(true), style: .large) } diff --git a/Twigs/Transaction/AddTransactionView.swift b/Twigs/Transaction/AddTransactionView.swift deleted file mode 100644 index d8bd6f1..0000000 --- a/Twigs/Transaction/AddTransactionView.swift +++ /dev/null @@ -1,102 +0,0 @@ -// -// AddTransactionView.swift -// Budget -// -// Created by Billy Brawner on 10/10/19. -// Copyright © 2019 William Brawner. All rights reserved. -// - -import SwiftUI -import Combine -import TwigsCore - -struct AddTransactionView: View { - @Binding var showSheet: Bool - @EnvironmentObject var authDataStore: AuthenticationDataStore - @EnvironmentObject var transactionDataStore: TransactionDataStore - @State var loading: Bool = false - @State var title: String = "" - @State var description: String = "" - @State var date: Date = Date() - @State var amount: String = "" - @State var type: TransactionType = .expense - @State var budgetId: String = "" - @State var categoryId: String = "" - var createdBy: String { - get { - return authDataStore.currentUser!.id - } - } - - @ViewBuilder - var stateContent: some View { - if let _ = transactionDataStore.transaction { - EmptyView().onAppear { - self.showSheet = false - } - } else if loading { - EmbeddedLoadingView() - } else { - EditTransactionForm( - title: self.$title, - description: self.$description, - date: self.$date, - amount: self.$amount, - type: self.$type, - budgetId: self.$budgetId, - categoryId: self.$categoryId, - deleteAction: nil - ) - } - } - - var body: some View { - NavigationView { - stateContent - .navigationBarItems( - leading: Button("cancel") { - self.showSheet = false - }, - trailing: Button("save") { - let amount = Double(self.amount) ?? 0.0 - Task { - try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction( - id: "", - title: self.title, - description: self.description, - date: self.date, - amount: Int(amount * 100.0), - categoryId: self.categoryId != "" ? self.categoryId : nil, - expense: self.type == TransactionType.expense, - createdBy: self.createdBy, - budgetId: self.budgetId - )) - } - }) - } - .onDisappear { - Task { - try await self.transactionDataStore.getTransactions(self.budgetId, categoryId: self.categoryId) - } - self.transactionDataStore.clearSelectedTransaction() - self.title = "" - self.description = "" - self.date = Date() - self.amount = "" - self.type = .expense - self.categoryId = "" - } - } - - init(showSheet: Binding, budgetId: String, categoryId: String = "") { - self._showSheet = showSheet - self._budgetId = State(initialValue: budgetId) - self._categoryId = State(initialValue: categoryId) - } -} - -//struct AddTransactionView_Previews: PreviewProvider { -// static var previews: some View { -// AddTransactionView() -// } -//} diff --git a/Twigs/Transaction/EditTransactionForm.swift b/Twigs/Transaction/EditTransactionForm.swift deleted file mode 100644 index 19a0cae..0000000 --- a/Twigs/Transaction/EditTransactionForm.swift +++ /dev/null @@ -1,119 +0,0 @@ -// -// EditTransactionView.swift -// Twigs -// -// Created by Billy Brawner on 10/14/19. -// Copyright © 2019 William Brawner. All rights reserved. -// - -import SwiftUI -import TwigsCore - -struct EditTransactionForm: View { - @EnvironmentObject var authDataStore: AuthenticationDataStore - @Binding var title: String - @Binding var description: String - @Binding var date: Date - @Binding var amount: String - @Binding var type: TransactionType - @Binding var budgetId: String - @Binding var categoryId: String - @State private var showingAlert = false - let deleteAction: (() -> ())? - - var body: some View { - Form { - TextField(LocalizedStringKey("prompt_name"), text: self.$title) - .textInputAutocapitalization(.words) - TextField(LocalizedStringKey("prompt_description"), text: self.$description) - .textInputAutocapitalization(.sentences) - DatePicker(selection: self.$date, label: { Text(LocalizedStringKey("prompt_date")) }) - TextField(LocalizedStringKey("prompt_amount"), text: self.$amount) - .keyboardType(.decimalPad) - Picker(LocalizedStringKey("prompt_type"), selection: self.$type) { - ForEach(TransactionType.allCases) { type in - Text(type.localizedKey) - } - } - BudgetPicker(self.$budgetId) - CategoryPicker(self.$budgetId, categoryId: self.$categoryId, expense: self.$type, apiService: self.authDataStore.apiService) - if deleteAction != nil { - Button(action: { - self.showingAlert = true - }) { - Text(LocalizedStringKey("delete")) - .foregroundColor(.red) - } - .alert(isPresented:$showingAlert) { - Alert(title: Text(LocalizedStringKey("confirm_delete")), message: Text(LocalizedStringKey("cannot_undo")), primaryButton: .destructive(Text(LocalizedStringKey("delete")), action: deleteAction), secondaryButton: .cancel()) - } - } else { - EmptyView() - } - } - } -} - -struct BudgetPicker: View { - var budgetId: Binding - - @ViewBuilder - var body: some View { - if let budgets = self.budgetsDataStore.budgets { - Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) { - ForEach(budgets) { budget in - Text(budget.name) - } - } - } else { - Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) { - Text("") - } - } - } - - @EnvironmentObject var budgetsDataStore: BudgetsDataStore - init(_ budgetId: Binding) { - self.budgetId = budgetId - } -} - -struct CategoryPicker: View { - let budgetId: Binding - var categoryId: Binding - let expense: Binding - - @ViewBuilder - var body: some View { - if let categories = self.categoryDataStore.categories { - Picker(LocalizedStringKey("prompt_category"), selection: self.categoryId) { - ForEach(categories) { category in - Text(category.title) - } - } - } else { - VStack { - ActivityIndicator(isAnimating: .constant(true), style: .medium) - }.onAppear { - Task { - try await self.categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, archived: false) - } - } - } - } - - @StateObject var categoryDataStore: CategoryListDataStore - init(_ budgetId: Binding, categoryId: Binding, expense: Binding, apiService: TwigsApiService) { - self.budgetId = budgetId - self.categoryId = categoryId - self.expense = expense - self._categoryDataStore = StateObject(wrappedValue: CategoryListDataStore(apiService)) - } -} - -// -//struct EditTransactionView_Previews: PreviewProvider { -// static var previews: some View { -// EditTransactionView() -// } -//} diff --git a/Twigs/Transaction/Transaction.swift b/Twigs/Transaction/Transaction.swift index 2740851..4efa6f0 100644 --- a/Twigs/Transaction/Transaction.swift +++ b/Twigs/Transaction/Transaction.swift @@ -21,18 +21,8 @@ extension TransactionType { } return LocalizedStringKey(key) } -} - -extension TwigsCore.Transaction { - var type: TransactionType { - if (self.expense) { - return .expense - } else { - return .income - } - } - var amountString: String { - return String(Double(self.amount) / 100.0) + func toBool() -> Bool { + return self == .expense } } diff --git a/Twigs/Transaction/TransactionDataStore.swift b/Twigs/Transaction/TransactionDataStore.swift index d1bf9d6..81b7b26 100644 --- a/Twigs/Transaction/TransactionDataStore.swift +++ b/Twigs/Transaction/TransactionDataStore.swift @@ -11,12 +11,22 @@ import Combine import Collections import TwigsCore -class TransactionDataStore: AsyncObservableObject { +class TransactionDataStore: ObservableObject { @Published var transactions: AsyncData> = .empty - @Published var transaction: AsyncData = .empty + @Published var transaction: AsyncData = .empty { + didSet { + if case let .success(transaction) = self.transaction { + self.selectedTransaction = transaction + } else if case .empty = self.transaction { + self.selectedTransaction = nil + } + } + } + @Published var selectedTransaction: Transaction? = nil func getTransactions(_ budgetId: String, categoryId: String? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) async { - try await load { + self.transactions = .loading + do { var categoryIds: [String] = [] if let categoryId = categoryId { categoryIds.append(categoryId) @@ -30,29 +40,51 @@ class TransactionDataStore: AsyncObservableObject { page: page ) let groupedTransactions = OrderedDictionary(grouping: transactions, by: { $0.date.toLocaleString() }) - self.transactions = groupedTransactions + self.transactions = .success(groupedTransactions) + } catch { + self.transactions = .error(error) } } func saveTransaction(_ transaction: Transaction) async { - try await load { + self.transaction = .saving(transaction) + do { + var savedTransaction: Transaction if (transaction.id != "") { - self.transaction = try await self.transactionRepository.updateTransaction(transaction) + savedTransaction = try await self.transactionRepository.updateTransaction(transaction) } else { - self.transaction = try await self.transactionRepository.createTransaction(transaction) + savedTransaction = try await self.transactionRepository.createTransaction(transaction) } + self.transaction = .success(savedTransaction) + } catch { + self.transaction = .error(error, transaction) } } - func deleteTransaction(_ transactionId: String) async { - try await load { - try await self.transactionRepository.deleteTransaction(transactionId) - self.transaction = nil + func deleteTransaction(_ transaction: Transaction) async { + self.transaction = .loading + do { + try await self.transactionRepository.deleteTransaction(transaction.id) + self.transaction = .empty + } catch { + self.transaction = .error(error, transaction) + } + } + + func editTransaction(_ transaction: Transaction) { + self.transaction = .editing(transaction) + } + + func cancelEdit() { + if let transaction = self.selectedTransaction { + self.transaction = .success(transaction) + } else { + self.transaction = .empty } } func clearSelectedTransaction() { - self.transaction = nil + self.transaction = .empty } private let transactionRepository: TransactionRepository diff --git a/Twigs/Transaction/TransactionDetails.swift b/Twigs/Transaction/TransactionDetails.swift new file mode 100644 index 0000000..72b9d52 --- /dev/null +++ b/Twigs/Transaction/TransactionDetails.swift @@ -0,0 +1,70 @@ +// +// TransactionDetail.swift +// Twigs +// +// Created by William Brawner on 1/4/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import Foundation +import TwigsCore + +@MainActor +class TransactionDetails: ObservableObject { + @Published var category: AsyncData = .empty + @Published var budget: AsyncData = .empty + @Published var user: AsyncData = .empty + let budgetRepository: BudgetRepository + let categoryRepository: CategoryRepository + let userRepository: UserRepository + + init(budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, userRepository: UserRepository) { + self.budgetRepository = budgetRepository + self.categoryRepository = categoryRepository + self.userRepository = userRepository + } + + func loadDetails(_ transaction: TwigsCore.Transaction) async { + Task { + await loadBudget(transaction.budgetId) + } + Task { + if let categoryId = transaction.categoryId { + await loadCategory(categoryId) + } + } + Task { + await loadUser(transaction.createdBy) + } + } + + private func loadBudget(_ id: String) async { + self.budget = .loading + do { + let budget = try await budgetRepository.getBudget(id) + self.budget = .success(budget) + } catch { + self.budget = .error(error) + } + } + + private func loadCategory(_ id: String) async { + self.category = .loading + do { + let category = try await categoryRepository.getCategory(id) + self.category = .success(category) + } catch { + self.category = .error(error) + } + } + + private func loadUser(_ id: String) async { + self.user = .loading + do { + let user = try await userRepository.getUser(id) + self.user = .success(user) + } catch { + self.user = .error(error) + } + } +} diff --git a/Twigs/Transaction/TransactionDetailsView.swift b/Twigs/Transaction/TransactionDetailsView.swift index 8897073..3d1f612 100644 --- a/Twigs/Transaction/TransactionDetailsView.swift +++ b/Twigs/Transaction/TransactionDetailsView.swift @@ -10,12 +10,26 @@ import SwiftUI import TwigsCore struct TransactionDetailsView: View { - @Environment(\.presentationMode) var presentationMode + @EnvironmentObject var apiService: TwigsApiService + @EnvironmentObject var authDataStore: AuthenticationDataStore @EnvironmentObject var dataStore: TransactionDataStore - @State var shouldNavigateUp: Bool = false - + @ObservedObject var transactionDetails: TransactionDetails + var editing: Bool { + if case .editing(_) = dataStore.transaction { + return true + } + if case .saving(_) = dataStore.transaction { + return true + } + return false + } + + init(_ transactionDetails: TransactionDetails) { + self.transactionDetails = transactionDetails + } + var body: some View { - if let transaction = self.dataStore.transaction { + if let transaction = self.dataStore.selectedTransaction { ScrollView { VStack(alignment: .leading) { Text(transaction.title) @@ -29,24 +43,36 @@ struct TransactionDetailsView: View { .font(.subheadline) .foregroundColor(.secondary) Spacer().frame(height: 20.0) - LabeledField(label: "notes", value: transaction.description, showDivider: true) - CategoryLineItem(transaction.categoryId) + if let description = transaction.description { + LabeledField(label: "notes", value: description, loading: .constant(false), showDivider: true) + } + CategoryLineItem() BudgetLineItem() - UserLineItem(transaction.createdBy) + UserLineItem() }.padding() + .environmentObject(transactionDetails) + .task { + await transactionDetails.loadDetails(transaction) + } } - .navigationBarItems(trailing: NavigationLink( - destination: TransactionEditView( - transaction, - shouldNavigateUp: self.$shouldNavigateUp - ).navigationBarTitle("edit") + .navigationBarItems(trailing: Button( + action: { self.dataStore.editTransaction(transaction) } ) { Text("edit") }) - } else { - EmbeddedLoadingView().onAppear { - self.presentationMode.wrappedValue.dismiss() + .sheet(isPresented: .constant(self.editing), onDismiss: nil) { + TransactionFormSheet(transactionForm: TransactionForm( + budgetRepository: apiService, + categoryRepository: apiService, + transactionList: dataStore, + createdBy: authDataStore.currentUser!.id, + budgetId: transaction.budgetId, + categoryId: transaction.categoryId, + transaction: transaction + )) } + } else { + EmbeddedLoadingView() } } } @@ -54,6 +80,7 @@ struct TransactionDetailsView: View { struct LabeledField: View { let label: LocalizedStringKey let value: String? + @Binding var loading: Bool let showDivider: Bool @ViewBuilder @@ -64,8 +91,12 @@ struct LabeledField: View { Text(self.label) .foregroundColor(.secondary) Spacer() - Text(verbatim: value ?? "") - .multilineTextAlignment(.trailing) + if loading { + EmbeddedLoadingView() + } else { + Text(verbatim: val) + .multilineTextAlignment(.trailing) + } } if showDivider { Divider() @@ -76,61 +107,72 @@ struct LabeledField: View { } struct CategoryLineItem: View { - var body: some View { - stateContent.onAppear { - if let id = self.categoryId { - Task { - try await categoryDataStore.getCategory(id) - } - } + @EnvironmentObject var transactionDetails: TransactionDetails + var value: String { + // TODO: Show errors + if case let .success(category) = transactionDetails.category { + return category.title + } else { + return "" } } @ViewBuilder - var stateContent: some View { - if let category = self.categoryDataStore.category { - LabeledField(label: "category", value: category.title, showDivider: true) + var body: some View { + if case .empty = transactionDetails.category { + EmptyView() } else { - LabeledField(label: "category", value: "", showDivider: true) + LabeledField(label: "category", value: value, loading: .constant(self.value == ""), showDivider: true) } } - - @EnvironmentObject var categoryDataStore: CategoryListDataStore - let categoryId: String? - init(_ categoryId: String?) { - self.categoryId = categoryId - } } struct BudgetLineItem: View { - @EnvironmentObject var budgetDataStore: BudgetsDataStore + @EnvironmentObject var transactionDetails: TransactionDetails + var value: String { + // TODO: Show errors + if case let .success(budget) = transactionDetails.budget { + return budget.name + } else { + return "" + } + } + @ViewBuilder var body: some View { - LabeledField(label: "budget", value: self.budgetDataStore.budget?.name, showDivider: true) + if case .empty = transactionDetails.budget { + EmptyView() + } else { + LabeledField(label: "budget", value: value, loading: .constant(self.value == ""), showDivider: true) + } } } struct UserLineItem: View { - - var body: some View { - LabeledField(label: "registered_by", value: userDataStore.user?.username, showDivider: false).onAppear { - Task { - try await userDataStore.getUser(userId) - } + @EnvironmentObject var transactionDetails: TransactionDetails + var value: String { + // TODO: Show errors + if case let .success(user) = transactionDetails.user { + return user.username + } else { + return "" } } - - @EnvironmentObject var userDataStore: UserDataStore - let userId: String - init(_ userId: String) { - self.userId = userId + + @ViewBuilder + var body: some View { + if case .empty = transactionDetails.user { + EmptyView() + } else { + LabeledField(label: "created_by", value: value, loading: .constant(self.value == ""), showDivider: false) + } } } #if DEBUG struct TransactionDetailsView_Previews: PreviewProvider { static var previews: some View { - TransactionDetailsView() + TransactionDetailsView(TransactionDetails(budgetRepository: MockBudgetRepository(), categoryRepository: MockCategoryRepository(), userRepository: MockUserRepository())) } } #endif diff --git a/Twigs/Transaction/TransactionEditView.swift b/Twigs/Transaction/TransactionEditView.swift deleted file mode 100644 index 67bbc3e..0000000 --- a/Twigs/Transaction/TransactionEditView.swift +++ /dev/null @@ -1,96 +0,0 @@ -// -// TransactionEditView.swift -// Twigs -// -// Created by Billy Brawner on 10/16/19. -// Copyright © 2019 William Brawner. All rights reserved. -// - -import SwiftUI -import TwigsCore - -struct TransactionEditView: View { - @State var loading: Bool = false - @Environment(\.presentationMode) var presentationMode - @State var title: String - @State var description: String - @State var date: Date - @State var amount: String - @State var type: TransactionType - @State var budgetId: String - @State var categoryId: String - var createdBy: String { - get { - return authDataStore.currentUser!.id - } - } - let id: String? - var shouldNavigateUp: Binding - - @ViewBuilder - var stateContent: some View { - if let _ = self.transactionDataStore.transaction { - EditTransactionForm( - title: self.$title, - description: self.$description, - date: self.$date, - amount: self.$amount, - type: self.$type, - budgetId: self.$budgetId, - categoryId: self.$categoryId, - deleteAction: { - Task { - self.loading = true - try await self.transactionDataStore.deleteTransaction(self.id!) - } - }) - } else { - EmbeddedLoadingView().onAppear { - self.shouldNavigateUp.wrappedValue = true - self.presentationMode.wrappedValue.dismiss() - } - } - } - - var body: some View { - stateContent - .navigationBarItems(trailing: Button("save") { - let amount = Double(self.amount) ?? 0.0 - Task { - try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction( - id: self.id ?? "", - title: self.title, - description: self.description, - date: self.date, - amount: Int(amount * 100.0), - categoryId: self.categoryId, - expense: self.type == TransactionType.expense, - createdBy: self.createdBy, - budgetId: self.budgetId - )) - } - }) - } - - @EnvironmentObject var transactionDataStore: TransactionDataStore - @EnvironmentObject var authDataStore: AuthenticationDataStore - init(_ transaction: TwigsCore.Transaction, shouldNavigateUp: Binding) { - self.id = transaction.id - self._title = State(initialValue: transaction.title) - self._description = State(initialValue: transaction.description ?? "") - self._date = State(initialValue: transaction.date) - self._amount = State(initialValue: transaction.amountString) - self._type = State(initialValue: transaction.type) - self._budgetId = State(initialValue: transaction.budgetId) - self._categoryId = State(initialValue: transaction.categoryId ?? "") - self.shouldNavigateUp = shouldNavigateUp - } -} - -#if DEBUG -struct TransactionEditView_Previews: PreviewProvider { - static var previews: some View { - TransactionEditView(MockTransactionRepository.transaction, shouldNavigateUp: .constant(false)) - } -} -#endif diff --git a/Twigs/Transaction/TransactionForm.swift b/Twigs/Transaction/TransactionForm.swift new file mode 100644 index 0000000..2ce0d1c --- /dev/null +++ b/Twigs/Transaction/TransactionForm.swift @@ -0,0 +1,108 @@ +// +// TransactionForm.swift +// Twigs +// +// Created by William Brawner on 1/4/22. +// Copyright © 2022 William Brawner. All rights reserved. +// + +import Foundation +import TwigsCore + +class TransactionForm: ObservableObject { + let budgetRepository: BudgetRepository + let categoryRepository: CategoryRepository + let transactionList: TransactionDataStore + let transaction: TwigsCore.Transaction? + let createdBy: String + let transactionId: String + @Published var title: String + @Published var description: String + @Published var date: Date + @Published var amount: String + @Published var type: TransactionType + @Published var budgetId: String { + didSet { + updateCategories() + } + } + @Published var categoryId: String + + @Published var budgets: AsyncData<[Budget]> = .empty + @Published var categories: AsyncData<[TwigsCore.Category]> = .empty + private var cachedCategories: [TwigsCore.Category] = [] + let showDelete: Bool + + init( + budgetRepository: BudgetRepository, + categoryRepository: CategoryRepository, + transactionList: TransactionDataStore, + createdBy: String, + budgetId: String, + categoryId: String? = nil, + transaction: TwigsCore.Transaction? = nil + ) { + self.budgetRepository = budgetRepository + self.categoryRepository = categoryRepository + self.budgetId = budgetId + self.categoryId = categoryId ?? "" + self.createdBy = createdBy + self.transactionList = transactionList + let baseTransaction = transaction ?? TwigsCore.Transaction(categoryId: categoryId, createdBy: createdBy, budgetId: budgetId) + self.transaction = transaction + self.transactionId = baseTransaction.id + self.title = baseTransaction.title + self.description = baseTransaction.description ?? "" + self.date = baseTransaction.date + self.amount = baseTransaction.amountString + self.type = baseTransaction.type + self.showDelete = !baseTransaction.id.isEmpty + } + + func load() async { + self.budgets = .loading + self.categories = .loading + var budgets: [Budget] + do { + budgets = try await budgetRepository.getBudgets(count: nil, page: nil) + self.budgets = .success(budgets) + } catch { + self.budgets = .error(error) + } + do { + let categories = try await categoryRepository.getCategories(budgetId: nil, expense: nil, archived: false, count: nil, page: nil) + self.cachedCategories = categories + updateCategories() + } catch { + self.categories = .error(error) + } + } + + func save() async { + let amount = Double(self.amount) ?? 0.0 + await transactionList.saveTransaction(Transaction( + id: transactionId, + title: title, + description: description, + date: date, + amount: Int(amount * 100.0), + categoryId: categoryId, + expense: type.toBool(), + createdBy: createdBy, + budgetId: budgetId + )) + } + + func delete() async { + guard let transaction = self.transaction else { + return + } + await transactionList.deleteTransaction(transaction) + } + + private func updateCategories() { + self.categories = .success(cachedCategories.filter { + $0.budgetId == self.budgetId && $0.expense == self.type.toBool() + }) + } +} diff --git a/Twigs/Transaction/TransactionFormSheet.swift b/Twigs/Transaction/TransactionFormSheet.swift new file mode 100644 index 0000000..dbc5672 --- /dev/null +++ b/Twigs/Transaction/TransactionFormSheet.swift @@ -0,0 +1,107 @@ +// +// EditTransactionView.swift +// Twigs +// +// Created by Billy Brawner on 10/14/19. +// Copyright © 2019 William Brawner. All rights reserved. +// + +import SwiftUI +import TwigsCore + +struct TransactionFormSheet: View { + @EnvironmentObject var transactionDataStore: TransactionDataStore + @ObservedObject var transactionForm: TransactionForm + @State private var showingAlert = false + + @ViewBuilder + var body: some View { + switch self.transactionDataStore.transaction { + case .loading: + EmbeddedLoadingView() + default: + Form { + TextField(LocalizedStringKey("prompt_name"), text: $transactionForm.title) + .textInputAutocapitalization(.words) + TextField(LocalizedStringKey("prompt_description"), text: $transactionForm.description) + .textInputAutocapitalization(.sentences) + DatePicker(selection: $transactionForm.date, label: { Text(LocalizedStringKey("prompt_date")) }) + TextField(LocalizedStringKey("prompt_amount"), text: $transactionForm.amount) + .keyboardType(.decimalPad) + Picker(LocalizedStringKey("prompt_type"), selection: $transactionForm.type) { + ForEach(TransactionType.allCases) { type in + Text(type.localizedKey) + } + } + BudgetPicker() + CategoryPicker() + if transactionForm.showDelete { + Button(action: { + self.showingAlert = true + }) { + Text(LocalizedStringKey("delete")) + .foregroundColor(.red) + } + .alert(isPresented:$showingAlert) { + Alert( + title: Text(LocalizedStringKey("confirm_delete")), + message: Text(LocalizedStringKey("cannot_undo")), + primaryButton: .destructive( + Text(LocalizedStringKey("delete")), + action: { Task { await transactionForm.delete() }} + ), + secondaryButton: .cancel() + ) + } + } else { + EmptyView() + } + } + } + } +} + +struct BudgetPicker: View { + @EnvironmentObject var transactionForm: TransactionForm + + @ViewBuilder + var body: some View { + if case let .success(budgets) = self.transactionForm.budgets { + Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { + ForEach(budgets) { budget in + Text(budget.name) + } + } + } else { + Picker(LocalizedStringKey("prompt_budget"), selection: $transactionForm.budgetId) { + Text("") + } + } + } +} + +struct CategoryPicker: View { + @EnvironmentObject var transactionForm: TransactionForm + + @ViewBuilder + var body: some View { + if case let .success(categories) = self.transactionForm.categories { + Picker(LocalizedStringKey("prompt_category"), selection: $transactionForm.categoryId) { + ForEach(categories) { category in + Text(category.title) + } + } + } else { + VStack { + ActivityIndicator(isAnimating: .constant(true), style: .medium) + } + } + } +} + +// +//struct EditTransactionView_Previews: PreviewProvider { +// static var previews: some View { +// EditTransactionView() +// } +//} diff --git a/Twigs/Transaction/TransactionListView.swift b/Twigs/Transaction/TransactionListView.swift index 81d0978..e1add71 100644 --- a/Twigs/Transaction/TransactionListView.swift +++ b/Twigs/Transaction/TransactionListView.swift @@ -12,12 +12,20 @@ import Collections import TwigsCore struct TransactionListView: View where Content: View { - @EnvironmentObject var transactionDataStore: TransactionDataStore - @State var requestId: String = "" - @State var isAddingTransaction = false + @EnvironmentObject var authDataStore: AuthenticationDataStore + @StateObject var transactionDataStore: TransactionDataStore + let apiService: TwigsApiService @State var search: String = "" - @ViewBuilder - let header: (() -> Content)? + @ViewBuilder let header: (() -> Content)? + var addingTransaction: Bool { + if case .editing(_) = self.transactionDataStore.transaction { + return true + } + if case .saving(_) = self.transactionDataStore.transaction { + return true + } + return false + } @ViewBuilder private func TransactionList(_ transactions: OrderedDictionary) -> some View { @@ -50,35 +58,46 @@ struct TransactionListView: View where Content: View { @ViewBuilder var body: some View { InlineLoadingView( - action: { try await transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id) }, + data: $transactionDataStore.transactions, + action: { await transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id) }, errorTextLocalizedStringKey: "Failed to load transactions" - ) { - if let transactions = self.transactionDataStore.transactions { - List { - TransactionList(transactions) - } - .searchable(text: $search) - .sheet(isPresented: $isAddingTransaction, content: { - AddTransactionView(showSheet: $isAddingTransaction, budgetId: self.budget.id) - .navigationBarTitle("add_transaction") - }) - .navigationBarItems( - trailing: HStack { - Button(action: { - self.isAddingTransaction = true - }) { - Image(systemName: "plus") - .padding() - } - } - ) + ) { transactions in + List { + TransactionList(transactions) } + .searchable(text: $search) + .sheet( + isPresented: .constant(addingTransaction), + content: { + TransactionFormSheet(transactionForm: TransactionForm( + budgetRepository: apiService, + categoryRepository: apiService, + transactionList: transactionDataStore, + createdBy: authDataStore.currentUser!.id, + budgetId: self.budget.id, + categoryId: self.category?.id, + transaction: nil + )) + .navigationBarTitle("add_transaction") + }) + .navigationBarItems( + trailing: HStack { + Button(action: { + transactionDataStore.editTransaction(TwigsCore.Transaction(createdBy: authDataStore.currentUser!.id, budgetId: budget.id)) + }) { + Image(systemName: "plus") + .padding() + } + } + ) } } let budget: Budget let category: TwigsCore.Category? - init(_ budget: Budget, category: TwigsCore.Category? = nil, header: (() -> Content)? = nil) { + init(apiService: TwigsApiService, budget: Budget, category: TwigsCore.Category? = nil, header: (() -> Content)? = nil) { + self.apiService = apiService + self._transactionDataStore = StateObject(wrappedValue: TransactionDataStore(apiService)) self.budget = budget self.category = category self.header = header @@ -87,14 +106,19 @@ struct TransactionListView: View where Content: View { struct TransactionListItemView: View { @EnvironmentObject var dataStore: TransactionDataStore + @EnvironmentObject var apiService: TwigsApiService var transaction: TwigsCore.Transaction var body: some View { NavigationLink( tag: self.transaction, - selection: self.$dataStore.transaction, + selection: self.$dataStore.selectedTransaction, destination: { - TransactionDetailsView().navigationBarTitle("details", displayMode: .inline) + TransactionDetailsView(TransactionDetails( + budgetRepository: apiService, + categoryRepository: apiService, + userRepository: apiService + )).navigationBarTitle("details", displayMode: .inline) }, label: { HStack { @@ -118,7 +142,6 @@ struct TransactionListItemView: View { } .padding(.leading) }.padding(5.0) - } ) } diff --git a/Twigs/TwigsApp.swift b/Twigs/TwigsApp.swift index 2e73a36..8776cc0 100644 --- a/Twigs/TwigsApp.swift +++ b/Twigs/TwigsApp.swift @@ -11,20 +11,21 @@ import TwigsCore @main struct TwigsApp: App { + @StateObject var apiService: TwigsInMemoryCacheService = TwigsInMemoryCacheService() @AppStorage("BASE_URL") var baseUrl: String = "" @AppStorage("TOKEN") var token: String = "" @AppStorage("USER_ID") var userId: String = "" - let apiService: TwigsInMemoryCacheService = TwigsInMemoryCacheService() - + var body: some Scene { WindowGroup { - MainView(self.apiService, baseUrl: self.$baseUrl, token: self.$token, userId: self.$userId).onAppear { - print("TwigsApp.onAppear") - if self.baseUrl != "", self.token != "" { - self.apiService.baseUrl = self.baseUrl - self.apiService.token = self.token + MainView(self.apiService, baseUrl: self.$baseUrl, token: self.$token, userId: self.$userId) + .environmentObject(apiService as TwigsApiService) + .onAppear { + if self.baseUrl != "", self.token != "" { + self.apiService.baseUrl = self.baseUrl + self.apiService.token = self.token + } } - } } } } diff --git a/Twigs/User/AuthenticationDataStore.swift b/Twigs/User/AuthenticationDataStore.swift index 7f3b65c..b6f8a42 100644 --- a/Twigs/User/AuthenticationDataStore.swift +++ b/Twigs/User/AuthenticationDataStore.swift @@ -3,10 +3,13 @@ import Combine import SwiftUI import TwigsCore +@MainActor class AuthenticationDataStore: ObservableObject { - @Published var loading: Bool = false { + @Published var user: AsyncData = .empty { didSet { - print("authDataStore loading: \(self.loading)") + if case let .success(user) = self.user { + currentUser = user + } } } @Published var currentUser: User? = nil { @@ -27,18 +30,17 @@ class AuthenticationDataStore: ObservableObject { self._userId = userId } - func login(server: String, username: String, password: String) async throws { - self.loading = true - defer { - self.loading = false - } + func login(server: String, username: String, password: String) async { + self.user = .loading self.apiService.baseUrl = server // The API Service applies some validation and correcting of the server before returning it so we use that // value instead of the original one self.baseUrl = self.apiService.baseUrl ?? "" - var response: LoginResponse do { - response = try await self.apiService.login(username: username, password: password) + let response = try await self.apiService.login(username: username, password: password) + self.token = response.token + self.userId = response.userId + await self.loadProfile() } catch { switch error { case NetworkError.jsonParsingFailed(let jsonError): @@ -46,18 +48,11 @@ class AuthenticationDataStore: ObservableObject { default: print(error.localizedDescription) } - return + self.user = .error(error) } - self.token = response.token - self.userId = response.userId - try await self.loadProfile() } - func register(server: String, username: String, email: String, password: String, confirmPassword: String) async throws { - self.loading = true - defer { - self.loading = false - } + func register(server: String, username: String, email: String, password: String, confirmPassword: String) async { // TODO: Validate other fields as well if !password.elementsEqual(confirmPassword) { // TODO: Show error message to user @@ -79,18 +74,20 @@ class AuthenticationDataStore: ObservableObject { } return } - try await self.login(server: server, username: username, password: password) + await self.login(server: server, username: username, password: password) } - func loadProfile() async throws { - self.loading = true - defer { - self.loading = false - } + func loadProfile() async { if userId == "" { - throw UserStatus.unauthenticated + self.user = .error(UserStatus.unauthenticated) + return + } + do { + let user = try await self.apiService.getUser(userId) + self.user = .success(user) + } catch { + self.user = .error(error) } - self.currentUser = try await self.apiService.getUser(userId) } } diff --git a/Twigs/Views/InlineLoadingView.swift b/Twigs/Views/InlineLoadingView.swift index 1efb282..7a13298 100644 --- a/Twigs/Views/InlineLoadingView.swift +++ b/Twigs/Views/InlineLoadingView.swift @@ -23,7 +23,7 @@ struct InlineLoadingView: View where Content: View, Data: Equatab .task { await action() } - case .error(let error): + case .error(let error, _): Text(LocalizedStringKey(errorTextLocalizedStringKey)) Text(error.localizedDescription) Button(LocalizedStringKey("action_retry"), action: { @@ -31,7 +31,7 @@ struct InlineLoadingView: View where Content: View, Data: Equatab await action() } }) - case .success(let data): + case .success(let data), .editing(let data), .saving(let data): successBody(data) } } @@ -40,7 +40,7 @@ struct InlineLoadingView: View where Content: View, Data: Equatab #if DEBUG struct InlineLoadingView_Previews: PreviewProvider { static var previews: some View { - InlineLoadingView(action: {}, errorTextLocalizedStringKey: "An error ocurred", successBody: { EmptyView() }) + InlineLoadingView(data: .constant(AsyncData.empty), action: {}, errorTextLocalizedStringKey: "An error ocurred", successBody: { _ in EmptyView() }) } } #endif diff --git a/Twigs/Views/MainView.swift b/Twigs/Views/MainView.swift index 59d7ecd..d6feffc 100644 --- a/Twigs/Views/MainView.swift +++ b/Twigs/Views/MainView.swift @@ -11,35 +11,35 @@ import TwigsCore struct MainView: View { @StateObject var authenticationDataStore: AuthenticationDataStore - @StateObject var budgetDataStore: BudgetsDataStore let apiService: TwigsApiService init(_ apiService: TwigsApiService, baseUrl: Binding, token: Binding, userId: Binding) { self.apiService = apiService self._authenticationDataStore = StateObject(wrappedValue: AuthenticationDataStore(apiService, baseUrl: baseUrl, token: token, userId: userId)) - self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService)) } @ViewBuilder var mainView: some View { if UIDevice.current.userInterfaceIdiom == .mac || UIDevice.current.userInterfaceIdiom == .pad { - SidebarBudgetView(apiService: apiService) - .environmentObject(authenticationDataStore) - .environmentObject(budgetDataStore) + SidebarBudgetView() } else { - TabbedBudgetView(apiService: apiService) - .environmentObject(authenticationDataStore) - .environmentObject(budgetDataStore) + TabbedBudgetView() } } var body: some View { - mainView.onAppear { - print("MainView.onAppear") - Task { - try await self.authenticationDataStore.loadProfile() + mainView + .environmentObject(TransactionDataStore(apiService)) + .environmentObject(CategoryListDataStore(apiService)) + .environmentObject(BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService)) + .environmentObject(UserDataStore(apiService)) + .environmentObject(RecurringTransactionDataStore(apiService)) + .environmentObject(authenticationDataStore) + .onAppear { + Task { + await self.authenticationDataStore.loadProfile() + } } - } } }