Finish migration to async/await implementation

This commit is contained in:
William Brawner 2022-01-04 20:51:38 -06:00
parent 27c7a51b1f
commit ec425642b4
31 changed files with 807 additions and 731 deletions

View file

@ -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 */,

View file

@ -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:

View file

@ -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 {

View file

@ -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") {

View file

@ -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)
}
}
}

View file

@ -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()))
}
}

View 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)
}
}

View file

@ -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

View file

@ -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() {

View file

@ -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
}
}

View file

@ -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()

View file

@ -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
*/

View file

@ -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

View file

@ -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

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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()
// }
//}

View file

@ -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()
// }
//}

View file

@ -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
}
}

View file

@ -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

View 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)
}
}
}

View file

@ -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

View file

@ -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

View 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()
})
}
}

View 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()
// }
//}

View file

@ -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)
}
)
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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

View file

@ -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()
}
}
}