Finish migration to async/await implementation
This commit is contained in:
parent
27c7a51b1f
commit
ec425642b4
31 changed files with 807 additions and 731 deletions
|
@ -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 = "<group>"; };
|
||||
282126612357E45F00072D52 /* TransactionEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionEditView.swift; sourceTree = "<group>"; };
|
||||
2821265F23555FD300072D52 /* TransactionFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionFormSheet.swift; sourceTree = "<group>"; };
|
||||
282126A0235929B800072D52 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
|
||||
282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsInMemoryCacheService.swift; sourceTree = "<group>"; };
|
||||
282126A4235BCB7500072D52 /* Twigs.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Twigs.entitlements; sourceTree = "<group>"; };
|
||||
|
@ -86,7 +86,6 @@
|
|||
284102312342E12F00EAFA29 /* CategoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListView.swift; sourceTree = "<group>"; };
|
||||
2857EAEC233DA30B0026BC83 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
|
||||
289510232352AAFC00BC862B /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.swift; sourceTree = "<group>"; };
|
||||
28A1E959235006A300CA57FE /* AddTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTransactionView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
28AC94F3233C373A00BFB70A /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
|
@ -113,7 +112,6 @@
|
|||
28FE6B03234449DC00D5543E /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = "<group>"; };
|
||||
28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = "<group>"; };
|
||||
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = "<group>"; };
|
||||
8005FD54277E61DC00E48B23 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||
8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
|
||||
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = "<group>"; };
|
||||
|
@ -123,6 +121,9 @@
|
|||
802161CF277647920075761A /* AsyncObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncObservableObject.swift; sourceTree = "<group>"; };
|
||||
8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = "<group>"; };
|
||||
8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = "<group>"; };
|
||||
8044BA3A2784B659009A78D4 /* TransactionDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetails.swift; sourceTree = "<group>"; };
|
||||
8044BA3C2784CC0D009A78D4 /* TransactionForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionForm.swift; sourceTree = "<group>"; };
|
||||
8044BA3E27853054009A78D4 /* CategoryForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryForm.swift; sourceTree = "<group>"; };
|
||||
806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = "<group>"; };
|
||||
80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = "<group>"; };
|
||||
808582CD277E5E9E00006859 /* TwigsCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TwigsCore; path = ../TwigsCore; sourceTree = "<group>"; };
|
||||
|
@ -186,6 +187,7 @@
|
|||
28FE6AFB23441E4500D5543E /* CategoryRepository.swift */,
|
||||
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */,
|
||||
8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */,
|
||||
8044BA3E27853054009A78D4 /* CategoryForm.swift */,
|
||||
);
|
||||
path = Category;
|
||||
sourceTree = "<group>";
|
||||
|
@ -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 = "<group>";
|
||||
|
@ -312,14 +313,6 @@
|
|||
path = User;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
8005FD53277E61DC00E48B23 /* twigs-cli */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
8005FD54277E61DC00E48B23 /* main.swift */,
|
||||
);
|
||||
path = "twigs-cli";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 */,
|
||||
|
|
|
@ -13,6 +13,8 @@ enum AsyncData<Data>: 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<Data>: 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:
|
||||
|
|
|
@ -19,8 +19,7 @@ struct BudgetDetailsView: View {
|
|||
data: self.$budgetDataStore.overview,
|
||||
action: { await self.budgetDataStore.loadOverview(self.budget) },
|
||||
errorTextLocalizedStringKey: "budgets_load_failure"
|
||||
) {
|
||||
if let overview = self.budgetDataStore.overview {
|
||||
) { overview in
|
||||
List {
|
||||
Section(overview.budget.name) {
|
||||
DescriptionOverview(overview: overview)
|
||||
|
@ -34,7 +33,6 @@ struct BudgetDetailsView: View {
|
|||
}.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DescriptionOverview: View {
|
||||
|
|
|
@ -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") {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
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) {
|
||||
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, amount: spent)
|
||||
LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining)
|
||||
LabeledCounter(title: middleLabel(category), amount: spent)
|
||||
LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: category.amount - spent)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
.onAppear {
|
||||
Task {
|
||||
try await self.sum = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil)
|
||||
}
|
||||
}.task {
|
||||
await categoryDataStore.sum(categoryId: category.id)
|
||||
}
|
||||
.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)
|
||||
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()))
|
||||
}
|
||||
}
|
||||
|
|
62
Twigs/Category/CategoryForm.swift
Normal file
62
Twigs/Category/CategoryForm.swift
Normal file
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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<Bool>, 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
|
||||
|
|
|
@ -13,7 +13,16 @@ import TwigsCore
|
|||
@MainActor
|
||||
class CategoryListDataStore: ObservableObject {
|
||||
@Published var categories: AsyncData<[TwigsCore.Category]> = .empty
|
||||
@Published var category: AsyncData<TwigsCore.Category> = .empty
|
||||
@Published var category: AsyncData<TwigsCore.Category> = .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
|
||||
|
@ -58,8 +67,16 @@ class CategoryListDataStore: ObservableObject {
|
|||
}
|
||||
}
|
||||
|
||||
func selectCategory(_ category: TwigsCore.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() {
|
||||
|
|
|
@ -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 {
|
||||
) { categories in
|
||||
List {
|
||||
Section {
|
||||
ForEach(categories.filter { !$0.archived }) { category in
|
||||
CategoryListItemView(budget, category: category)
|
||||
CategoryListItemView(CategoryDataStore(transactionRepository: apiService), budget: budget, category: category)
|
||||
}
|
||||
}
|
||||
Section("Archived") {
|
||||
ForEach(categories.filter { $0.archived }) { category in
|
||||
CategoryListItemView(budget, category: category)
|
||||
}
|
||||
CategoryListItemView(CategoryDataStore(transactionRepository: apiService), budget: budget, category: category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -46,8 +46,14 @@ 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 {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -6,13 +6,15 @@
|
|||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
import TwigsCore
|
||||
|
||||
class TwigsInMemoryCacheService: TwigsApiService {
|
||||
private var budgets = Set<Budget>()
|
||||
private var categories = Set<TwigsCore.Category>()
|
||||
private var transactions = Set<Transaction>()
|
||||
private var transactions = Set<TwigsCore.Transaction>()
|
||||
|
||||
|
||||
public init() {
|
||||
super.init(RequestHelper())
|
||||
|
@ -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
|
||||
*/
|
||||
|
|
|
@ -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<RecurringTransaction> = .empty
|
||||
@Published var transaction: AsyncData<RecurringTransaction> = .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
|
||||
|
|
|
@ -10,9 +10,10 @@ import SwiftUI
|
|||
import TwigsCore
|
||||
|
||||
struct RecurringTransactionDetailsView: View {
|
||||
let transaction: RecurringTransaction
|
||||
@EnvironmentObject var dataStore: RecurringTransactionDataStore
|
||||
|
||||
var body: some View {
|
||||
if let transaction = dataStore.selectedTransaction {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(transaction.title)
|
||||
|
@ -26,24 +27,21 @@ struct RecurringTransactionDetailsView: View {
|
|||
}
|
||||
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)
|
||||
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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<EmptyView>(budget).navigationBarTitle("transactions") },
|
||||
destination: { TransactionListView<EmptyView>(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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<EmptyView>(budget)
|
||||
.sheet(isPresented: $isAddingTransaction,
|
||||
onDismiss: {
|
||||
isAddingTransaction = false
|
||||
},
|
||||
content: {
|
||||
AddTransactionView(showSheet: self.$isAddingTransaction, budgetId: budget.id)
|
||||
.navigationBarTitle("add_transaction")
|
||||
})
|
||||
TransactionListView<EmptyView>(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)
|
||||
}
|
||||
|
|
|
@ -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<Bool>, 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()
|
||||
// }
|
||||
//}
|
|
@ -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<String>
|
||||
|
||||
@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<String>) {
|
||||
self.budgetId = budgetId
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryPicker: View {
|
||||
let budgetId: Binding<String>
|
||||
var categoryId: Binding<String>
|
||||
let expense: Binding<TransactionType>
|
||||
|
||||
@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<String>, categoryId: Binding<String>, expense: Binding<TransactionType>, 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()
|
||||
// }
|
||||
//}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -11,12 +11,22 @@ import Combine
|
|||
import Collections
|
||||
import TwigsCore
|
||||
|
||||
class TransactionDataStore: AsyncObservableObject {
|
||||
class TransactionDataStore: ObservableObject {
|
||||
@Published var transactions: AsyncData<OrderedDictionary<String, [Transaction]>> = .empty
|
||||
@Published var transaction: AsyncData<Transaction> = .empty
|
||||
@Published var transaction: AsyncData<Transaction> = .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<String,[Transaction]>(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
|
||||
|
|
70
Twigs/Transaction/TransactionDetails.swift
Normal file
70
Twigs/Transaction/TransactionDetails.swift
Normal file
|
@ -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<TwigsCore.Category> = .empty
|
||||
@Published var budget: AsyncData<Budget> = .empty
|
||||
@Published var user: AsyncData<User> = .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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
BudgetLineItem()
|
||||
UserLineItem(transaction.createdBy)
|
||||
}.padding()
|
||||
if let description = transaction.description {
|
||||
LabeledField(label: "notes", value: description, loading: .constant(false), showDivider: true)
|
||||
}
|
||||
.navigationBarItems(trailing: NavigationLink(
|
||||
destination: TransactionEditView(
|
||||
transaction,
|
||||
shouldNavigateUp: self.$shouldNavigateUp
|
||||
).navigationBarTitle("edit")
|
||||
CategoryLineItem()
|
||||
BudgetLineItem()
|
||||
UserLineItem()
|
||||
}.padding()
|
||||
.environmentObject(transactionDetails)
|
||||
.task {
|
||||
await transactionDetails.loadDetails(transaction)
|
||||
}
|
||||
}
|
||||
.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,9 +91,13 @@ struct LabeledField: View {
|
|||
Text(self.label)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
Text(verbatim: value ?? "")
|
||||
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 {
|
||||
@EnvironmentObject var transactionDetails: TransactionDetails
|
||||
var value: String {
|
||||
// TODO: Show errors
|
||||
if case let .success(user) = transactionDetails.user {
|
||||
return user.username
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
LabeledField(label: "registered_by", value: userDataStore.user?.username, showDivider: false).onAppear {
|
||||
Task {
|
||||
try await userDataStore.getUser(userId)
|
||||
if case .empty = transactionDetails.user {
|
||||
EmptyView()
|
||||
} else {
|
||||
LabeledField(label: "created_by", value: value, loading: .constant(self.value == ""), showDivider: false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@EnvironmentObject var userDataStore: UserDataStore
|
||||
let userId: String
|
||||
init(_ userId: String) {
|
||||
self.userId = userId
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct TransactionDetailsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TransactionDetailsView()
|
||||
TransactionDetailsView(TransactionDetails(budgetRepository: MockBudgetRepository(), categoryRepository: MockCategoryRepository(), userRepository: MockUserRepository()))
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -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<Bool>
|
||||
|
||||
@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<Bool>) {
|
||||
self.id = transaction.id
|
||||
self._title = State<String>(initialValue: transaction.title)
|
||||
self._description = State<String>(initialValue: transaction.description ?? "")
|
||||
self._date = State<Date>(initialValue: transaction.date)
|
||||
self._amount = State<String>(initialValue: transaction.amountString)
|
||||
self._type = State<TransactionType>(initialValue: transaction.type)
|
||||
self._budgetId = State<String>(initialValue: transaction.budgetId)
|
||||
self._categoryId = State<String>(initialValue: transaction.categoryId ?? "")
|
||||
self.shouldNavigateUp = shouldNavigateUp
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
struct TransactionEditView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TransactionEditView(MockTransactionRepository.transaction, shouldNavigateUp: .constant(false))
|
||||
}
|
||||
}
|
||||
#endif
|
108
Twigs/Transaction/TransactionForm.swift
Normal file
108
Twigs/Transaction/TransactionForm.swift
Normal file
|
@ -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()
|
||||
})
|
||||
}
|
||||
}
|
107
Twigs/Transaction/TransactionFormSheet.swift
Normal file
107
Twigs/Transaction/TransactionFormSheet.swift
Normal file
|
@ -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()
|
||||
// }
|
||||
//}
|
|
@ -12,12 +12,20 @@ import Collections
|
|||
import TwigsCore
|
||||
|
||||
struct TransactionListView<Content>: 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<String, [TwigsCore.Transaction]>) -> some View {
|
||||
|
@ -50,22 +58,32 @@ struct TransactionListView<Content>: 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 {
|
||||
) { transactions in
|
||||
List {
|
||||
TransactionList(transactions)
|
||||
}
|
||||
.searchable(text: $search)
|
||||
.sheet(isPresented: $isAddingTransaction, content: {
|
||||
AddTransactionView(showSheet: $isAddingTransaction, budgetId: self.budget.id)
|
||||
.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: {
|
||||
self.isAddingTransaction = true
|
||||
transactionDataStore.editTransaction(TwigsCore.Transaction(createdBy: authDataStore.currentUser!.id, budgetId: budget.id))
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
.padding()
|
||||
|
@ -74,11 +92,12 @@ struct TransactionListView<Content>: View where Content: View {
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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<Content>: 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)
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
@ -11,15 +11,16 @@ 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")
|
||||
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
|
||||
|
|
|
@ -3,10 +3,13 @@ import Combine
|
|||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
@MainActor
|
||||
class AuthenticationDataStore: ObservableObject {
|
||||
@Published var loading: Bool = false {
|
||||
@Published var user: AsyncData<User> = .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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ struct InlineLoadingView<Content, Data>: 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<Content, Data>: 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<Content, Data>: 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<Never>.empty), action: {}, errorTextLocalizedStringKey: "An error ocurred", successBody: { _ in EmptyView() })
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -11,33 +11,33 @@ import TwigsCore
|
|||
|
||||
struct MainView: View {
|
||||
@StateObject var authenticationDataStore: AuthenticationDataStore
|
||||
@StateObject var budgetDataStore: BudgetsDataStore
|
||||
let apiService: TwigsApiService
|
||||
|
||||
init(_ apiService: TwigsApiService, baseUrl: Binding<String>, token: Binding<String>, userId: Binding<String>) {
|
||||
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")
|
||||
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 {
|
||||
try await self.authenticationDataStore.loadProfile()
|
||||
await self.authenticationDataStore.loadProfile()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue