WIP: Convert codebase to async/await

This commit is contained in:
William Brawner 2022-01-03 11:56:43 -06:00
parent bcab5fa078
commit 27c7a51b1f
44 changed files with 1122 additions and 2020 deletions

View file

@ -14,11 +14,9 @@
282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */; }; 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */; };
282126BD235CDE1400072D52 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BC235CDE1400072D52 /* ProgressView.swift */; }; 282126BD235CDE1400072D52 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BC235CDE1400072D52 /* ProgressView.swift */; };
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */; }; 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */; };
2841022C2342D8E400EAFA29 /* Budget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022B2342D8E400EAFA29 /* Budget.swift */; };
284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022F2342D97300EAFA29 /* BudgetListsView.swift */; }; 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2841022F2342D97300EAFA29 /* BudgetListsView.swift */; };
284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102312342E12F00EAFA29 /* CategoryListView.swift */; }; 284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102312342E12F00EAFA29 /* CategoryListView.swift */; };
2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2857EAEC233DA30B0026BC83 /* LoadingView.swift */; }; 2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2857EAEC233DA30B0026BC83 /* LoadingView.swift */; };
2888234723512DBF003D3847 /* Observable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2888234623512DBF003D3847 /* Observable.swift */; };
289510242352AAFC00BC862B /* UserDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 289510232352AAFC00BC862B /* UserDataStore.swift */; }; 289510242352AAFC00BC862B /* UserDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 289510232352AAFC00BC862B /* UserDataStore.swift */; };
28A1E95A235006A300CA57FE /* AddTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A1E959235006A300CA57FE /* AddTransactionView.swift */; }; 28A1E95A235006A300CA57FE /* AddTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A1E959235006A300CA57FE /* AddTransactionView.swift */; };
28AC94F2233C373900BFB70A /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94F1233C373900BFB70A /* LoginView.swift */; }; 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94F1233C373900BFB70A /* LoginView.swift */; };
@ -28,32 +26,34 @@
28AC9505233C373A00BFB70A /* BudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9504233C373A00BFB70A /* BudgetTests.swift */; }; 28AC9505233C373A00BFB70A /* BudgetTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9504233C373A00BFB70A /* BudgetTests.swift */; };
28AC9510233C373A00BFB70A /* BudgetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC950F233C373A00BFB70A /* BudgetUITests.swift */; }; 28AC9510233C373A00BFB70A /* BudgetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC950F233C373A00BFB70A /* BudgetUITests.swift */; };
28AC951F233C381C00BFB70A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 28AC9521233C381C00BFB70A /* Localizable.strings */; }; 28AC951F233C381C00BFB70A /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 28AC9521233C381C00BFB70A /* Localizable.strings */; };
28AC9525233C42D100BFB70A /* TwigsApiService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9524233C42D100BFB70A /* TwigsApiService.swift */; };
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9528233C433400BFB70A /* TransactionRepository.swift */; }; 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC9528233C433400BFB70A /* TransactionRepository.swift */; };
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC952B233C434800BFB70A /* UserRepository.swift */; }; 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC952B233C434800BFB70A /* UserRepository.swift */; };
28AC952E233C43A300BFB70A /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC952D233C43A300BFB70A /* User.swift */; };
28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */; }; 28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */; };
28CE8B9523525F990072BC4C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CE8B9423525F990072BC4C /* Extensions.swift */; }; 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CE8B9423525F990072BC4C /* Extensions.swift */; };
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */; }; 28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */; };
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */; }; 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */; };
28FE6AF823441E1D00D5543E /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF723441E1D00D5543E /* Category.swift */; }; 28FE6AFA23441E3700D5543E /* CategoryListDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */; };
28FE6AFA23441E3700D5543E /* CategoryDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF923441E3700D5543E /* CategoryDataStore.swift */; };
28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */; }; 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */; };
28FE6B002344308600D5543E /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFF2344308600D5543E /* Transaction.swift */; }; 28FE6B002344308600D5543E /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFF2344308600D5543E /* Transaction.swift */; };
28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B012344331B00D5543E /* TransactionDataStore.swift */; }; 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B012344331B00D5543E /* TransactionDataStore.swift */; };
28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B03234449DC00D5543E /* TransactionListView.swift */; }; 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B03234449DC00D5543E /* TransactionListView.swift */; };
28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */; }; 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */; };
543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */; }; 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */; };
8005FD5B277E623900E48B23 /* TwigsCore in Frameworks */ = {isa = PBXBuildFile; productRef = 8005FD5A277E623900E48B23 /* TwigsCore */; };
8005FD5D277EAB0200E48B23 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8005FD5C277EAB0200E48B23 /* MainView.swift */; };
800DFC2C277FF47A00EDCE9B /* AsyncData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 800DFC2B277FF47A00EDCE9B /* AsyncData.swift */; };
801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */; }; 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */; };
801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */; }; 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */; };
801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */; }; 801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */; };
801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */; }; 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */; };
802161D0277647920075761A /* AsyncObservableObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802161CF277647920075761A /* AsyncObservableObject.swift */; };
8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */; }; 8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */; };
8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */; };
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; }; 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; };
80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; }; 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; };
8094A9C327567CAC006C6C62 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8094A9C227567CAC006C6C62 /* Collections */; }; 8094A9C327567CAC006C6C62 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8094A9C227567CAC006C6C62 /* Collections */; };
809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; }; 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; };
80D6B1F1275B11DE0075D0EC /* RecurringTransaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */; }; 80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
@ -82,11 +82,9 @@
282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetDetailsView.swift; sourceTree = "<group>"; }; 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetDetailsView.swift; sourceTree = "<group>"; };
282126BC235CDE1400072D52 /* ProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressView.swift; sourceTree = "<group>"; }; 282126BC235CDE1400072D52 /* ProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressView.swift; sourceTree = "<group>"; };
2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedBudgetView.swift; sourceTree = "<group>"; }; 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedBudgetView.swift; sourceTree = "<group>"; };
2841022B2342D8E400EAFA29 /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = "<group>"; };
2841022F2342D97300EAFA29 /* BudgetListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetListsView.swift; sourceTree = "<group>"; }; 2841022F2342D97300EAFA29 /* BudgetListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetListsView.swift; sourceTree = "<group>"; };
284102312342E12F00EAFA29 /* CategoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListView.swift; sourceTree = "<group>"; }; 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>"; }; 2857EAEC233DA30B0026BC83 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = "<group>"; };
2888234623512DBF003D3847 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = "<group>"; };
289510232352AAFC00BC862B /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.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>"; }; 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; }; 28AC94EA233C373900BFB70A /* Twigs.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Twigs.app; sourceTree = BUILT_PRODUCTS_DIR; };
@ -102,32 +100,35 @@
28AC950F233C373A00BFB70A /* BudgetUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetUITests.swift; sourceTree = "<group>"; }; 28AC950F233C373A00BFB70A /* BudgetUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetUITests.swift; sourceTree = "<group>"; };
28AC9511233C373A00BFB70A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; }; 28AC9511233C373A00BFB70A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
28AC9520233C381C00BFB70A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; }; 28AC9520233C381C00BFB70A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
28AC9524233C42D100BFB70A /* TwigsApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApiService.swift; sourceTree = "<group>"; };
28AC9528233C433400BFB70A /* TransactionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRepository.swift; sourceTree = "<group>"; }; 28AC9528233C433400BFB70A /* TransactionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRepository.swift; sourceTree = "<group>"; };
28AC952B233C434800BFB70A /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = "<group>"; }; 28AC952B233C434800BFB70A /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = "<group>"; };
28AC952D233C43A300BFB70A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = "<group>"; };
28B9E50D2346BCB2007C3909 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; }; 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = "<group>"; };
28CE8B9423525F990072BC4C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; }; 28CE8B9423525F990072BC4C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = "<group>"; };
28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsDataStore.swift; sourceTree = "<group>"; }; 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsDataStore.swift; sourceTree = "<group>"; };
28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetRepository.swift; sourceTree = "<group>"; }; 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetRepository.swift; sourceTree = "<group>"; };
28FE6AF723441E1D00D5543E /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = "<group>"; }; 28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListDataStore.swift; sourceTree = "<group>"; };
28FE6AF923441E3700D5543E /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = "<group>"; };
28FE6AFB23441E4500D5543E /* CategoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryRepository.swift; sourceTree = "<group>"; }; 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryRepository.swift; sourceTree = "<group>"; };
28FE6AFF2344308600D5543E /* Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = "<group>"; }; 28FE6AFF2344308600D5543E /* Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = "<group>"; };
28FE6B012344331B00D5543E /* TransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDataStore.swift; sourceTree = "<group>"; }; 28FE6B012344331B00D5543E /* TransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDataStore.swift; sourceTree = "<group>"; };
28FE6B03234449DC00D5543E /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = "<group>"; }; 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>"; }; 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>"; }; 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>"; }; 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = "<group>"; };
801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsRepository.swift; sourceTree = "<group>"; }; 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsRepository.swift; sourceTree = "<group>"; };
801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDataStore.swift; sourceTree = "<group>"; }; 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDataStore.swift; sourceTree = "<group>"; };
801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = "<group>"; }; 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = "<group>"; };
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>"; }; 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>"; };
806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.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>"; }; 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>"; };
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = "<group>"; }; 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = "<group>"; };
809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; }; 809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransaction.swift; sourceTree = "<group>"; }; 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -135,6 +136,7 @@
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
8005FD5B277E623900E48B23 /* TwigsCore in Frameworks */,
8094A9C327567CAC006C6C62 /* Collections in Frameworks */, 8094A9C327567CAC006C6C62 /* Collections in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -168,7 +170,6 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2841022F2342D97300EAFA29 /* BudgetListsView.swift */, 2841022F2342D97300EAFA29 /* BudgetListsView.swift */,
2841022B2342D8E400EAFA29 /* Budget.swift */,
28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */, 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */,
28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */, 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */,
282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */, 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */,
@ -179,12 +180,12 @@
2841022A2342D8CB00EAFA29 /* Category */ = { 2841022A2342D8CB00EAFA29 /* Category */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
28FE6AF723441E1D00D5543E /* Category.swift */, 28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */,
28FE6AF923441E3700D5543E /* CategoryDataStore.swift */,
8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */, 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */,
284102312342E12F00EAFA29 /* CategoryListView.swift */, 284102312342E12F00EAFA29 /* CategoryListView.swift */,
28FE6AFB23441E4500D5543E /* CategoryRepository.swift */, 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */,
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */, 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */,
8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */,
); );
path = Category; path = Category;
sourceTree = "<group>"; sourceTree = "<group>";
@ -194,6 +195,8 @@
children = ( children = (
2857EAEC233DA30B0026BC83 /* LoadingView.swift */, 2857EAEC233DA30B0026BC83 /* LoadingView.swift */,
282126BC235CDE1400072D52 /* ProgressView.swift */, 282126BC235CDE1400072D52 /* ProgressView.swift */,
80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */,
8005FD5C277EAB0200E48B23 /* MainView.swift */,
); );
path = Views; path = Views;
sourceTree = "<group>"; sourceTree = "<group>";
@ -201,10 +204,13 @@
28AC94E1233C373900BFB70A = { 28AC94E1233C373900BFB70A = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
80DBED432774AE4F00CB0A88 /* Packages */,
8005FD53277E61DC00E48B23 /* twigs-cli */,
28AC94EB233C373900BFB70A /* Products */, 28AC94EB233C373900BFB70A /* Products */,
28AC94EC233C373900BFB70A /* Twigs */, 28AC94EC233C373900BFB70A /* Twigs */,
28AC9503233C373A00BFB70A /* TwigsTests */, 28AC9503233C373A00BFB70A /* TwigsTests */,
28AC950E233C373A00BFB70A /* TwigsUITests */, 28AC950E233C373A00BFB70A /* TwigsUITests */,
8005FD59277E623900E48B23 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -225,7 +231,6 @@
28AC94FB233C373A00BFB70A /* Info.plist */, 28AC94FB233C373A00BFB70A /* Info.plist */,
28CE8B9423525F990072BC4C /* Extensions.swift */, 28CE8B9423525F990072BC4C /* Extensions.swift */,
28AC94F1233C373900BFB70A /* LoginView.swift */, 28AC94F1233C373900BFB70A /* LoginView.swift */,
2888234623512DBF003D3847 /* Observable.swift */,
28B9E50D2346BCB2007C3909 /* RegistrationView.swift */, 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */,
80820144275FFD380040996E /* SidebarBudgetView.swift */, 80820144275FFD380040996E /* SidebarBudgetView.swift */,
2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */, 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */,
@ -242,6 +247,8 @@
28AC9526233C42F800BFB70A /* Transaction */, 28AC9526233C42F800BFB70A /* Transaction */,
28AC952A233C433C00BFB70A /* User */, 28AC952A233C433C00BFB70A /* User */,
2857EAEB233DA2F90026BC83 /* Views */, 2857EAEB233DA2F90026BC83 /* Views */,
802161CF277647920075761A /* AsyncObservableObject.swift */,
800DFC2B277FF47A00EDCE9B /* AsyncData.swift */,
); );
path = Twigs; path = Twigs;
sourceTree = "<group>"; sourceTree = "<group>";
@ -290,7 +297,6 @@
28AC9527233C430A00BFB70A /* Network */ = { 28AC9527233C430A00BFB70A /* Network */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
28AC9524233C42D100BFB70A /* TwigsApiService.swift */,
282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */, 282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */,
); );
path = Network; path = Network;
@ -300,17 +306,30 @@
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
28AC952B233C434800BFB70A /* UserRepository.swift */, 28AC952B233C434800BFB70A /* UserRepository.swift */,
28AC952D233C43A300BFB70A /* User.swift */,
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */, 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */,
289510232352AAFC00BC862B /* UserDataStore.swift */, 289510232352AAFC00BC862B /* UserDataStore.swift */,
); );
path = User; path = User;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
8005FD53277E61DC00E48B23 /* twigs-cli */ = {
isa = PBXGroup;
children = (
8005FD54277E61DC00E48B23 /* main.swift */,
);
path = "twigs-cli";
sourceTree = "<group>";
};
8005FD59277E623900E48B23 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
80D6B1EF275B11C10075D0EC /* Recurring Transactions */ = { 80D6B1EF275B11C10075D0EC /* Recurring Transactions */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */,
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */, 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */,
801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */, 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */,
801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */, 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */,
@ -319,6 +338,14 @@
path = "Recurring Transactions"; path = "Recurring Transactions";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
80DBED432774AE4F00CB0A88 /* Packages */ = {
isa = PBXGroup;
children = (
808582CD277E5E9E00006859 /* TwigsCore */,
);
name = Packages;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -337,6 +364,7 @@
name = Twigs; name = Twigs;
packageProductDependencies = ( packageProductDependencies = (
8094A9C227567CAC006C6C62 /* Collections */, 8094A9C227567CAC006C6C62 /* Collections */,
8005FD5A277E623900E48B23 /* TwigsCore */,
); );
productName = Budget; productName = Budget;
productReference = 28AC94EA233C373900BFB70A /* Twigs.app */; productReference = 28AC94EA233C373900BFB70A /* Twigs.app */;
@ -384,7 +412,7 @@
28AC94E2233C373900BFB70A /* Project object */ = { 28AC94E2233C373900BFB70A /* Project object */ = {
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1100; LastSwiftUpdateCheck = 1320;
LastUpgradeCheck = 1250; LastUpgradeCheck = 1250;
ORGANIZATIONNAME = "William Brawner"; ORGANIZATIONNAME = "William Brawner";
TargetAttributes = { TargetAttributes = {
@ -462,40 +490,39 @@
2821266023555FD300072D52 /* EditTransactionForm.swift in Sources */, 2821266023555FD300072D52 /* EditTransactionForm.swift in Sources */,
282126622357E45F00072D52 /* TransactionEditView.swift in Sources */, 282126622357E45F00072D52 /* TransactionEditView.swift in Sources */,
28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */, 28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */,
2841022C2342D8E400EAFA29 /* Budget.swift in Sources */,
801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */, 801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */,
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */, 2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */,
801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */, 801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */,
28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */, 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */,
80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */,
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */, 28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */,
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */, 28AC952C233C434800BFB70A /* UserRepository.swift in Sources */,
28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */, 28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */,
28AC94F2233C373900BFB70A /* LoginView.swift in Sources */, 28AC94F2233C373900BFB70A /* LoginView.swift in Sources */,
802161D0277647920075761A /* AsyncObservableObject.swift in Sources */,
282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */, 282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */,
801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */, 801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */,
28AC9525233C42D100BFB70A /* TwigsApiService.swift in Sources */,
2888234723512DBF003D3847 /* Observable.swift in Sources */,
2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */, 2857EAED233DA30B0026BC83 /* LoadingView.swift in Sources */,
282126A3235ABC1800072D52 /* TwigsInMemoryCacheService.swift in Sources */, 282126A3235ABC1800072D52 /* TwigsInMemoryCacheService.swift in Sources */,
28FE6AFA23441E3700D5543E /* CategoryDataStore.swift in Sources */, 800DFC2C277FF47A00EDCE9B /* AsyncData.swift in Sources */,
28FE6AFA23441E3700D5543E /* CategoryListDataStore.swift in Sources */,
28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */, 28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */,
284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */, 284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */,
8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */, 8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */,
289510242352AAFC00BC862B /* UserDataStore.swift in Sources */, 289510242352AAFC00BC862B /* UserDataStore.swift in Sources */,
28FE6B002344308600D5543E /* Transaction.swift in Sources */, 28FE6B002344308600D5543E /* Transaction.swift in Sources */,
28FE6AF823441E1D00D5543E /* Category.swift in Sources */, 8005FD5D277EAB0200E48B23 /* MainView.swift in Sources */,
282126A1235929B800072D52 /* ProfileView.swift in Sources */, 282126A1235929B800072D52 /* ProfileView.swift in Sources */,
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */,
809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */,
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */,
80D6B1F1275B11DE0075D0EC /* RecurringTransaction.swift in Sources */,
28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */, 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */,
543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */, 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */,
80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */, 80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */,
8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */,
284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */, 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */,
282126BD235CDE1400072D52 /* ProgressView.swift in Sources */, 282126BD235CDE1400072D52 /* ProgressView.swift in Sources */,
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */, 806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */,
28AC952E233C43A300BFB70A /* User.swift in Sources */,
28CE8B9523525F990072BC4C /* Extensions.swift in Sources */, 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */,
801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */, 801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */,
); );
@ -866,6 +893,10 @@
/* End XCRemoteSwiftPackageReference section */ /* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */ /* Begin XCSwiftPackageProductDependency section */
8005FD5A277E623900E48B23 /* TwigsCore */ = {
isa = XCSwiftPackageProductDependency;
productName = TwigsCore;
};
8094A9C227567CAC006C6C62 /* Collections */ = { 8094A9C227567CAC006C6C62 /* Collections */ = {
isa = XCSwiftPackageProductDependency; isa = XCSwiftPackageProductDependency;
package = 8094A9C127567CAC006C6C62 /* XCRemoteSwiftPackageReference "swift-collections" */; package = 8094A9C127567CAC006C6C62 /* XCRemoteSwiftPackageReference "swift-collections" */;

33
Twigs/AsyncData.swift Normal file
View file

@ -0,0 +1,33 @@
//
// AsyncData.swift
// Twigs
//
// Created by William Brawner on 12/31/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import Foundation
import SwiftUI
enum AsyncData<Data>: Equatable where Data: Equatable {
case empty
case loading
case error(Error, Data? = nil)
case success(Data)
static func == (lhs: AsyncData, rhs: AsyncData) -> Bool {
switch (lhs, rhs) {
case (.empty, .empty):
return true
case (.loading, .loading):
return true
case (.error(let lError, let lData), .error(let rError, let rData)):
return lError.localizedDescription == rError.localizedDescription
&& ((lData == nil && rData == nil) || lData == rData)
case (.success(let lData), .success(let rData)):
return lData == rData
default:
return false
}
}
}

View file

@ -0,0 +1,32 @@
//
// AsyncObservableObject.swift
// Twigs
//
// Created by William Brawner on 12/24/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import Foundation
import TwigsCore
class AsyncObservableObject: ObservableObject {
@Published var loading: Bool = false
func load<T>(block: () async throws -> T) async throws -> T {
self.loading = true
defer {
self.loading = false
}
do {
return try await block()
} catch {
switch error {
case NetworkError.jsonParsingFailed(let wrappedError):
print("\(wrappedError.localizedDescription)")
default:
print("\(error.localizedDescription)")
}
throw error
}
}
}

View file

@ -1,25 +0,0 @@
//
// Budget.swift
// Budget
//
// Created by Billy Brawner on 9/30/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
struct Budget: Identifiable, Hashable, Codable {
let id: String
let name: String
let description: String?
let currencyCode: String?
}
struct BudgetOverview {
let budget: Budget
let balance: Int
var expectedIncome: Int = 0
var actualIncome: Int = 0
var expectedExpenses: Int = 0
var actualExpenses: Int = 0
}

View file

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct BudgetDetailsView: View { struct BudgetDetailsView: View {
@EnvironmentObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore
@ -14,56 +15,81 @@ struct BudgetDetailsView: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch budgetDataStore.overview { InlineLoadingView(
case .failure(.loading): data: self.$budgetDataStore.overview,
ActivityIndicator(isAnimating: .constant(true), style: .large) action: { await self.budgetDataStore.loadOverview(self.budget) },
case .success(let overview): errorTextLocalizedStringKey: "budgets_load_failure"
List { ) {
Section(overview.budget.name) { if let overview = self.budgetDataStore.overview {
VStack(alignment: .leading) { List {
if let description = overview.budget.description { Section(overview.budget.name) {
Text(description) DescriptionOverview(overview: overview)
}
HStack {
Text("current_balance")
Text(verbatim: overview.balance.toCurrencyString())
.foregroundColor(overview.balance < 0 ? .red : .green)
}
} }
} Section("income") {
Section("income") { IncomeOverview(overview: overview)
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedIncome.toCurrencyString())
}
ProgressView(value: Float(overview.expectedIncome), maxValue: Float(max(overview.expectedIncome, overview.actualIncome)), progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualIncome.toCurrencyString())
.foregroundColor(.green)
}
ProgressView(value: Float(overview.actualIncome), maxValue: Float(max(overview.expectedIncome, overview.actualIncome)), progressTintColor: .green, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
} }
} Section("expenses") {
Section("expenses") { ExpensesOverview(overview: overview)
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedExpenses.toCurrencyString())
}
ProgressView(value: Float(overview.expectedExpenses), maxValue: Float(max(overview.expectedExpenses, overview.actualExpenses)), progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualExpenses.toCurrencyString())
.foregroundColor(.red)
}
ProgressView(value: Float(overview.actualExpenses), maxValue: Float(max(overview.expectedExpenses, overview.actualExpenses)), progressTintColor: .red, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
} }
} }.listStyle(.insetGrouped)
}.listStyle(.insetGrouped) }
default: }
Text("An error has ocurred") }
}
struct DescriptionOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
if let description = overview.budget.description {
Text(description)
}
HStack {
Text("current_balance")
Text(verbatim: overview.balance.toCurrencyString())
.foregroundColor(overview.balance < 0 ? .red : .green)
}
}
}
}
struct IncomeOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedIncome.toCurrencyString())
}
ProgressView(value: Float(overview.expectedIncome), maxValue: Float(max(overview.expectedIncome, overview.actualIncome)), progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualIncome.toCurrencyString())
.foregroundColor(.green)
}
ProgressView(value: Float(overview.actualIncome), maxValue: Float(max(overview.expectedIncome, overview.actualIncome)), progressTintColor: .green, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
}
}
}
struct ExpensesOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedExpenses.toCurrencyString())
}
ProgressView(value: Float(overview.expectedExpenses), maxValue: Float(max(overview.expectedExpenses, overview.actualExpenses)), progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualExpenses.toCurrencyString())
.foregroundColor(.red)
}
ProgressView(value: Float(overview.actualExpenses), maxValue: Float(max(overview.expectedExpenses, overview.actualExpenses)), progressTintColor: .red, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
} }
} }
} }

View file

@ -9,27 +9,21 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TwigsCore
struct BudgetListsView: View { struct BudgetListsView: View {
@EnvironmentObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore
@ViewBuilder
var body: some View { var body: some View {
switch budgetDataStore.budgets { InlineLoadingView(
case .success(let budgets): action: { return try await self.budgetDataStore.getBudgets(count: nil, page: nil) },
errorTextLocalizedStringKey: "budgets_load_failure"
) { (budgets: [Budget]) in
Section("budgets") { Section("budgets") {
ForEach(budgets) { budget in ForEach(budgets) { budget in
BudgetListItemView(budget) BudgetListItemView(budget)
} }
} }
case .failure(.loading):
ActivityIndicator(isAnimating: .constant(true), style: .large)
default:
// TODO: Handle each network failure type
Text("budgets_load_failure").navigationBarTitle("budgets")
Button("action_retry", action: {
self.budgetDataStore.getBudgets()
})
} }
} }
} }

View file

@ -8,14 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
protocol BudgetRepository {
func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError>
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError>
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError>
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError>
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError>
}
#if DEBUG #if DEBUG
class MockBudgetRepository: BudgetRepository { class MockBudgetRepository: BudgetRepository {
@ -26,29 +19,28 @@ class MockBudgetRepository: BudgetRepository {
currencyCode: "USD" currencyCode: "USD"
) )
func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError> { func getBudgets(count: Int?, page: Int?) async throws -> [Budget] {
return Result.Publisher([MockBudgetRepository.budget]).eraseToAnyPublisher() return [MockBudgetRepository.budget]
} }
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> { func getBudget(_ id: String) async throws -> Budget {
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() return MockBudgetRepository.budget
} }
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> { func newBudget(_ budget: Budget) async throws -> Budget {
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() return MockBudgetRepository.budget
} }
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> { func updateBudget(_ budget: Budget) async throws -> Budget {
return Result.Publisher(Budget( return Budget(
id: "1", id: "1",
name: "Test Budget", name: "Test Budget",
description: "A mock budget used for testing", description: "A mock budget used for testing",
currencyCode: "USD" currencyCode: "USD"
)).eraseToAnyPublisher() )
} }
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError> { func deleteBudget(_ id: String) async throws {
return Result.Publisher(Empty()).eraseToAnyPublisher()
} }
} }
#endif #endif

View file

@ -8,177 +8,92 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
private let LAST_BUDGET = "LAST_BUDGET"
@MainActor
class BudgetsDataStore: ObservableObject { class BudgetsDataStore: ObservableObject {
private let budgetRepository: BudgetRepository private let budgetRepository: BudgetRepository
private let categoryRepository: CategoryRepository private let categoryRepository: CategoryRepository
private let transactionRepository: TransactionRepository private let transactionRepository: TransactionRepository
private var currentRequest: AnyCancellable? = nil @Published var budgets: AsyncData<[Budget]> = .empty
@Published var budgets: Result<[Budget], NetworkError> = .failure(.loading) @Published var budget: AsyncData<Budget> = .empty {
@Published var budget: Result<Budget, NetworkError>? = .failure(.loading) {
didSet { didSet {
self.overview = .empty
if case let .success(budget) = self.budget { if case let .success(budget) = self.budget {
UserDefaults.standard.set(budget.id, forKey: LAST_BUDGET) UserDefaults.standard.set(budget.id, forKey: LAST_BUDGET)
self.showBudgetSelection = false self.showBudgetSelection = false
loadOverview(budget) Task {
await loadOverview(budget)
}
} }
} }
} }
@Published var overview: Result<BudgetOverview, NetworkError> = .failure(.loading) @Published var overview: AsyncData<BudgetOverview> = .empty
@Published var showBudgetSelection: Bool = true @Published var showBudgetSelection: Bool = true
init(budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, transactionRepository: TransactionRepository) { init(budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, transactionRepository: TransactionRepository) {
self.budgetRepository = budgetRepository self.budgetRepository = budgetRepository
self.categoryRepository = categoryRepository self.categoryRepository = categoryRepository
self.transactionRepository = transactionRepository self.transactionRepository = transactionRepository
self.getBudgets(count: nil, page: nil)
} }
func getBudgets(count: Int? = nil, page: Int? = nil) { func getBudgets(count: Int? = nil, page: Int? = nil) async {
self.budgets = .failure(.loading) // TODO: Find some way to extract this to a generic function
self.budgets = .loading
self.currentRequest = self.budgetRepository.getBudgets(count: count, page: page) do {
.receive(on: DispatchQueue.main) let budgets = try await self.budgetRepository.getBudgets(count: count, page: page).sorted(by: { $0.name < $1.name })
.sink(receiveCompletion: { (status) in self.budgets = .success(budgets)
switch status { if self.budget != .empty {
case .finished: return
self.currentRequest = nil }
return if let id = UserDefaults.standard.string(forKey: LAST_BUDGET), let lastBudget = budgets.first(where: { $0.id == id }) {
case .failure(let error): self.budget = .success(lastBudget)
switch error { } else {
case .jsonParsingFailed(let wrappedError): if let budget = budgets.first {
if let networkError = wrappedError as? NetworkError { self.budget = .success(budget)
print("failed to load budgets: \(networkError.name)")
}
default:
print("failed to load budgets: \(error.name)")
}
self.budgets = .failure(error)
return
} }
}, receiveValue: { (budgets) in }
self.budgets = .success(budgets.sorted(by: { $0.name < $1.name })) } catch {
if case .success(_) = self.budget { self.budgets = .error(error)
// Don't do anything here }
} else {
if let id = UserDefaults.standard.string(forKey: LAST_BUDGET) {
if let budget = budgets.first(where: { $0.id == id }) {
self.budget = .success(budget)
} else {
self.budget = nil
}
} else {
if let budget = budgets.first {
self.budget = .success(budget)
} else {
self.budget = nil
}
}
}
})
} }
func loadOverview(_ budget: Budget) { func loadOverview(_ budget: Budget) async {
self.overview = .failure(.loading) self.overview = .loading
self.currentRequest = self.transactionRepository.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil) do {
.receive(on: DispatchQueue.main) let budgetBalance = try await self.transactionRepository.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil)
.sink(receiveCompletion: { (status) in let categories = try await self.categoryRepository.getCategories(budgetId: budget.id, expense: nil, archived: false, count: nil, page: nil)
switch status { var budgetOverview = BudgetOverview(budget: budget, balance: budgetBalance.balance)
case .finished: try await withThrowingTaskGroup(of: (TwigsCore.Category, BalanceResponse).self) { group in
return for category in categories {
case .failure(let error): group.addTask {
switch error { return (category, try await self.transactionRepository.sumTransactions(budgetId: nil, categoryId: category.id, from: nil, to: nil))
case .jsonParsingFailed(let wrappedError):
if let networkError = wrappedError as? NetworkError {
print("failed to load budget overview: \(networkError.name)")
}
default:
print("failed to load budget overview: \(error.name)")
} }
self.budgets = .failure(error)
self.currentRequest = nil
return
} }
}, receiveValue: { (response) in
self.sumCategories(budget: budget, balance: response.balance) for try await (category, response) in group {
})
}
private func sumCategories(budget: Budget, balance: Int) {
self.currentRequest = self.categoryRepository.getCategories(budgetId: budget.id, expense: nil, archived: false, count: nil, page: nil)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (status) in
switch status {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
switch error {
case .jsonParsingFailed(let wrappedError):
if let networkError = wrappedError as? NetworkError {
print("failed to load budget overview: \(networkError.name)")
}
default:
print("failed to load budget overview: \(error.name)")
}
self.budgets = .failure(error)
return
}
}, receiveValue: { (categories) in
var budgetOverview = BudgetOverview(budget: budget, balance: balance)
budgetOverview.expectedIncome = 0
budgetOverview.expectedIncome = 0
budgetOverview.actualIncome = 0
budgetOverview.actualIncome = 0
var categorySums: [AnyPublisher<CategoryBalance, NetworkError>] = []
categories.forEach { category in
if category.expense { if category.expense {
budgetOverview.expectedExpenses += category.amount budgetOverview.expectedExpenses += category.amount
} else { } else {
budgetOverview.expectedIncome += category.amount budgetOverview.expectedIncome += category.amount
} }
categorySums.append(self.transactionRepository.sumTransactions(budgetId: nil, categoryId: category.id, from: nil, to: nil).map {
CategoryBalance(category: category, balance: $0.balance) if category.expense {
}.eraseToAnyPublisher()) budgetOverview.actualExpenses += abs(response.balance)
} else {
budgetOverview.actualIncome += response.balance
}
} }
}
self.currentRequest = Publishers.MergeMany(categorySums) self.overview = .success(budgetOverview)
.collect() } catch {
.receive(on: DispatchQueue.main) self.overview = .error(error)
.sink(receiveCompletion: { status in }
switch status {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
switch error {
case .jsonParsingFailed(let wrappedError):
if let networkError = wrappedError as? NetworkError {
print("failed to load budget overview: \(networkError.name)")
}
default:
print("failed to load budget overview: \(error.name)")
}
self.overview = .failure(error)
return
}
}, receiveValue: {
$0.forEach { categoryBalance in
if categoryBalance.category.expense {
budgetOverview.actualExpenses += abs(categoryBalance.balance)
} else {
budgetOverview.actualIncome += categoryBalance.balance
}
}
self.overview = .success(budgetOverview)
})
})
} }
func selectBudget(_ budget: Budget) { func selectBudget(_ budget: Budget) {
self.budget = .success(budget) self.budget = .success(budget)
} }
} }
private let LAST_BUDGET = "LAST_BUDGET"

View file

@ -1,24 +0,0 @@
//
// Category.swift
// Budget
//
// Created by Billy Brawner on 10/1/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
struct Category: Identifiable, Hashable, Codable {
let budgetId: String
let id: String
let title: String
let description: String?
let amount: Int
let expense: Bool
let archived: Bool
}
struct CategoryBalance {
let category: Category
let balance: Int
}

View file

@ -1,112 +1,45 @@
// //
// CategoryDataStore.swift // CategoryDataStore.swift
// Budget // Twigs
// //
// Created by Billy Brawner on 10/1/19. // Created by William Brawner on 1/2/22.
// Copyright © 2019 William Brawner. All rights reserved. // Copyright © 2022 William Brawner. All rights reserved.
// //
import Foundation import Foundation
import Combine import TwigsCore
@MainActor
class CategoryDataStore: ObservableObject { class CategoryDataStore: ObservableObject {
private var currentRequest: AnyCancellable? = nil @Published var sum: AsyncData<Int> = .empty
@Published var categories: [String:Result<[Category], NetworkError>] = ["":.failure(.loading)] let transactionRepository: TransactionRepository
@Published var category: Result<Category, NetworkError> = .failure(.unknown)
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = false, count: Int? = nil, page: Int? = nil) -> String { init(transactionRepository: TransactionRepository) {
let requestId = "\(budgetId ?? "all")-\(String(describing: expense))-\(String(describing: archived))" self.transactionRepository = transactionRepository
self.categories[requestId] = .failure(.loading)
self.currentRequest = categoryRepository.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.objectWillChange.send() // TODO: Remove hack after finding better way to update dictionary values
self.categories[requestId] = .failure(error)
}
}, receiveValue: { (categories) in
print("Received \(categories.count) categories")
self.objectWillChange.send() // TODO: Remove hack after finding better way to update dictionary values
self.categories[requestId] = .success(categories)
})
return requestId
} }
func getCategory(_ categoryId: String) { func sum(categoryId: String, from: Date? = nil, to: Date? = nil) async {
self.category = .failure(.loading) self.sum = .loading
do {
self.currentRequest = categoryRepository.getCategory(categoryId) let sum = try await self.transactionRepository.sumTransactions(budgetId: nil, categoryId: categoryId, from: from, to: to).balance
.receive(on: DispatchQueue.main) self.sum = .success(sum)
.sink(receiveCompletion: { (completion) in } catch {
switch completion { self.sum = .error(error)
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.category = .failure(error)
}
}, receiveValue: { (category) in
self.category = .success(category)
})
}
func selectCategory(_ category: Category) {
self.category = .success(category)
}
func save(_ category: Category) {
self.category = .failure(.loading)
var savePublisher: AnyPublisher<Category, NetworkError>
if (category.id != "") {
savePublisher = self.categoryRepository.updateCategory(category)
} else {
savePublisher = self.categoryRepository.createCategory(category)
} }
self.currentRequest = savePublisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.category = .failure(error)
}
}, receiveValue: { (category) in
self.category = .success(category)
})
} }
func delete(_ id: String) { func save(_ category: TwigsCore.Category) async {
self.category = .failure(.loading) self.category = .loading
self.currentRequest = self.categoryRepository.deleteCategory(id) do {
.receive(on: DispatchQueue.main) var savedCategory: TwigsCore.Category
.sink(receiveCompletion: { (completion) in if category.id != "" {
switch completion { savedCategory = try await self.categoryRepository.updateCategory(category)
case .finished: } else {
self.currentRequest = nil savedCategory = try await self.categoryRepository.createCategory(category)
return }
case .failure(let error): self.category = .success(savedCategory)
self.category = .failure(error) } catch {
} self.category = .error(error, category)
}, receiveValue: { _ in }
self.category = .failure(.deleted)
})
}
func clearSelectedCategory() {
self.category = .failure(.unknown)
}
private let categoryRepository: CategoryRepository
init(_ categoryRepository: CategoryRepository) {
self.categoryRepository = categoryRepository
} }
} }

View file

@ -7,17 +7,18 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct CategoryDetailsView: View { struct CategoryDetailsView: View {
@EnvironmentObject var transactionDataStore: TransactionDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore
let budget: Budget let budget: Budget
let category: Category let category: TwigsCore.Category
@State var sumRequest: String = "" @State var sum: Int? = 0
@State var editingCategory: Bool = false @State var editingCategory: Bool = false
var spent: Int { var spent: Int {
get { get {
if case let .success(res) = transactionDataStore.sums[sumRequest] { if let sum = self.sum {
return abs(res.balance) return abs(sum)
} else { } else {
return 0 return 0
} }
@ -39,19 +40,21 @@ struct CategoryDetailsView: View {
} }
var body: some View { var body: some View {
TransactionListView(self.budget, category: category, header: VStack { TransactionListView(self.budget, category: category) {
Text(verbatim: category.description ?? "") VStack {
.padding() Text(verbatim: category.description ?? "")
HStack { .padding()
LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount) HStack {
LabeledCounter(title: middleLabel, amount: spent) LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount)
LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining) LabeledCounter(title: middleLabel, amount: spent)
} LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining)
}.frame(maxWidth: .infinity, alignment: .center).eraseToAnyView()) }
}.frame(maxWidth: .infinity, alignment: .center)
}
.onAppear { .onAppear {
if sumRequest == "" || !sumRequest.contains(category.id) { Task {
sumRequest = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil) try await self.sum = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil)
} }
} }
.navigationBarItems(trailing: Button(action: { .navigationBarItems(trailing: Button(action: {
@ -67,7 +70,7 @@ struct CategoryDetailsView: View {
}) })
} }
init (_ category: Category, budget: Budget) { init (_ category: TwigsCore.Category, budget: Budget) {
self.category = category self.category = category
self.budget = budget self.budget = budget
} }

View file

@ -7,9 +7,11 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct CategoryFormSheet: View { struct CategoryFormSheet: View {
@EnvironmentObject var categoryDataStore: CategoryDataStore @EnvironmentObject var categoryDataStore: CategoryListDataStore
@State var loading: Bool = false
@Binding var showSheet: Bool @Binding var showSheet: Bool
@State var title: String @State var title: String
@State var description: String @State var description: String
@ -20,15 +22,16 @@ struct CategoryFormSheet: View {
let budgetId: String let budgetId: String
@State private var showingAlert = false @State private var showingAlert = false
var stateContent: AnyView { @ViewBuilder
switch categoryDataStore.category { var stateContent: some View {
case .success(_): if let _ = self.categoryDataStore.category {
self.showSheet = false EmbeddedLoadingView().onAppear {
return AnyView(EmptyView()) self.showSheet = false
case .failure(.loading): }
return AnyView(EmbeddedLoadingView()) } else if self.loading {
default: EmbeddedLoadingView()
return AnyView(Form { } else {
Form {
TextField("prompt_name", text: self.$title) TextField("prompt_name", text: self.$title)
.textInputAutocapitalization(.words) .textInputAutocapitalization(.words)
TextField("prompt_description", text: self.$description) TextField("prompt_description", text: self.$description)
@ -50,13 +53,17 @@ struct CategoryFormSheet: View {
} }
.alert(isPresented:$showingAlert) { .alert(isPresented:$showingAlert) {
Alert(title: Text("confirm_delete"), message: Text("cannot_undo"), primaryButton: .destructive(Text("delete"), action: { Alert(title: Text("confirm_delete"), message: Text("cannot_undo"), primaryButton: .destructive(Text("delete"), action: {
self.categoryDataStore.delete(categoryId) Task {
self.loading = true
try await self.categoryDataStore.delete(categoryId)
self.showSheet = false
}
}), secondaryButton: .cancel()) }), secondaryButton: .cancel())
} }
} else { } else {
EmptyView() EmptyView()
} }
}) }
} }
} }
@ -70,23 +77,28 @@ struct CategoryFormSheet: View {
}, },
trailing: Button("save") { trailing: Button("save") {
let amount = Double(self.amount) ?? 0.0 let amount = Double(self.amount) ?? 0.0
self.categoryDataStore.save(Category( Task {
budgetId: self.budgetId, try await self.categoryDataStore.save(Category(
id: self.categoryId, budgetId: self.budgetId,
title: self.title, id: self.categoryId,
description: self.description, title: self.title,
amount: Int(amount * 100.0), description: self.description,
expense: self.type == TransactionType.expense, amount: Int(amount * 100.0),
archived: false expense: self.type == TransactionType.expense,
)) archived: false
))
}
}) })
}.onDisappear { }.onDisappear {
self.categoryDataStore.clearSelectedCategory() if categoryId.isEmpty {
self.categoryDataStore.clearSelectedCategory()
}
self.loading = false
} }
} }
init(showSheet: Binding<Bool>, category: Category?, budgetId: String) { init(showSheet: Binding<Bool>, category: TwigsCore.Category?, budgetId: String) {
let initialCategory = category ?? Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false) let initialCategory = category ?? TwigsCore.Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false)
self._showSheet = showSheet self._showSheet = showSheet
self._title = State(initialValue: initialCategory.title) self._title = State(initialValue: initialCategory.title)
self._description = State(initialValue: initialCategory.description ?? "") self._description = State(initialValue: initialCategory.description ?? "")
@ -108,7 +120,7 @@ struct CategoryFormSheet: View {
struct CategoryFormSheet_Previews: PreviewProvider { struct CategoryFormSheet_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
CategoryFormSheet(showSheet: .constant(true), category: nil, budgetId: "") CategoryFormSheet(showSheet: .constant(true), category: nil, budgetId: "")
.environmentObject(CategoryDataStore(MockCategoryRepository())) .environmentObject(CategoryListDataStore(MockCategoryRepository()))
} }
} }
#endif #endif

View file

@ -0,0 +1,73 @@
//
// CategoryListDataStore.swift
// Budget
//
// Created by Billy Brawner on 10/1/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
import TwigsCore
@MainActor
class CategoryListDataStore: ObservableObject {
@Published var categories: AsyncData<[TwigsCore.Category]> = .empty
@Published var category: AsyncData<TwigsCore.Category> = .empty
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = false, count: Int? = nil, page: Int? = nil) async {
self.categories = .loading
do {
let categories = try await categoryRepository.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page)
self.categories = .success(categories)
} catch {
self.categories = .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)
if case let .success(categories) = self.categories {
var updatedCategories = categories.filter(withoutId: category.id)
updatedCategories.append(savedCategory)
self.categories = .success(updatedCategories.sorted(by: { $0.title < $1.title }))
}
} catch {
self.category = .error(error, category)
}
}
func delete(_ category: TwigsCore.Category) async {
self.category = .loading
do {
try await self.categoryRepository.deleteCategory(category.id)
self.category = .empty
if case let .success(categories) = self.categories {
self.categories = .success(categories.filter(withoutId: category.id))
}
} catch {
self.category = .error(error, category)
}
}
func selectCategory(_ category: TwigsCore.Category) {
self.category = .success(category)
}
func clearSelectedCategory() {
self.category = .empty
}
private let categoryRepository: CategoryRepository
init(_ categoryRepository: CategoryRepository) {
self.categoryRepository = categoryRepository
}
}

View file

@ -8,35 +8,32 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TwigsCore
struct CategoryListView: View { struct CategoryListView: View {
@EnvironmentObject var categoryDataStore: CategoryDataStore @EnvironmentObject var categoryDataStore: CategoryListDataStore
@State var requestId: String = "" @State var requestId: String = ""
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch self.categoryDataStore.categories[requestId] { InlineLoadingView(
case .success(let categories): action: { try await self.categoryDataStore.getCategories(budgetId: budget.id, expense: nil, archived: nil, count: nil, page: nil) },
Section { errorTextLocalizedStringKey: "Failed to load categories"
List(categories) { category in ) {
CategoryListItemView(budget, category: category) if let categories = self.categoryDataStore.categories {
} List {
} Section {
case .failure(.loading): ForEach(categories.filter { !$0.archived }) { category in
VStack { CategoryListItemView(budget, category: category)
ActivityIndicator(isAnimating: .constant(true), style: .large)
.onAppear {
if requestId == "" {
requestId = categoryDataStore.getCategories(budgetId: budget.id, archived: false)
} }
} }
Section("Archived") {
ForEach(categories.filter { $0.archived }) { category in
CategoryListItemView(budget, category: category)
}
}
}
} }
default:
// TODO: Handle each network failure type
Text("budgets_load_failure")
Button("action_retry", action: {
requestId = categoryDataStore.getCategories(budgetId: budget.id, archived: false)
})
} }
} }
@ -47,9 +44,9 @@ struct CategoryListView: View {
} }
struct CategoryListItemView: View { struct CategoryListItemView: View {
var category: Category let category: TwigsCore.Category
let budget: Budget let budget: Budget
@State var sumId: String = "" @State var sum: Int? = nil
@EnvironmentObject var transactionDataStore: TransactionDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore
var progressTintColor: Color { var progressTintColor: Color {
@ -67,31 +64,36 @@ struct CategoryListItemView: View {
destination: CategoryDetailsView(category, budget: self.budget) destination: CategoryDetailsView(category, budget: self.budget)
.navigationBarTitle(category.title) .navigationBarTitle(category.title)
) { ) {
VStack(alignment: .leading) { InlineLoadingView(action: {
HStack { self.sum = try await transactionDataStore.sum(categoryId: category.id)
Text(verbatim: category.title) }, errorTextLocalizedStringKey: "Failed to load category balance") {
Spacer() VStack(alignment: .leading) {
remaining HStack {
Text(verbatim: category.title)
Spacer()
remaining
}
if category.description?.isEmpty == false {
Text(verbatim: category.description!)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
progressView
} }
if category.description?.isEmpty == false {
Text(verbatim: category.description!)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(1)
}
progressView
} }
}.onAppear { }.onAppear {
if self.sumId == "" { Task {
self.sumId = transactionDataStore.sum(categoryId: category.id) self.sum = try await transactionDataStore.sum(categoryId: category.id)
} }
} }
} }
var progressView: ProgressView { var progressView: ProgressView {
var balance: Float = 0.0 var balance: Float = 0.0
if case .success(let sum) = transactionDataStore.sums[sumId] { if let sum = self.sum {
balance = Float(abs(sum.balance)) balance = Float(abs(sum))
} }
return ProgressView(value: balance, maxValue: Float(category.amount), progressTintColor: progressTintColor, progressBarHeight: 4.0) return ProgressView(value: balance, maxValue: Float(category.amount), progressTintColor: progressTintColor, progressBarHeight: 4.0)
} }
@ -99,8 +101,8 @@ struct CategoryListItemView: View {
var remaining: Text { var remaining: Text {
var remaining = "" var remaining = ""
var color = Color.primary var color = Color.primary
if case .success(let sum) = transactionDataStore.sums[sumId] { if let sum = self.sum {
let amount = category.amount - abs(sum.balance) let amount = category.amount - abs(sum)
if amount < 0 { if amount < 0 {
remaining = abs(amount).toCurrencyString() + " over budget" remaining = abs(amount).toCurrencyString() + " over budget"
if category.expense { if category.expense {
@ -115,7 +117,7 @@ struct CategoryListItemView: View {
return Text(verbatim: remaining).foregroundColor(color) return Text(verbatim: remaining).foregroundColor(color)
} }
init (_ budget: Budget, category: Category) { init (_ budget: Budget, category: TwigsCore.Category) {
self.budget = budget self.budget = budget
self.category = category self.category = category
} }

View file

@ -8,18 +8,11 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
protocol CategoryRepository {
func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError>
func getCategory(_ categoryId: String) -> AnyPublisher<Category, NetworkError>
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError>
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError>
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError>
}
#if DEBUG #if DEBUG
class MockCategoryRepository: CategoryRepository { class MockCategoryRepository: CategoryRepository {
static let category = Category( static let category = TwigsCore.Category(
budgetId: MockBudgetRepository.budget.id, budgetId: MockBudgetRepository.budget.id,
id: "3", id: "3",
title: "Test Category", title: "Test Category",
@ -29,24 +22,24 @@ class MockCategoryRepository: CategoryRepository {
archived: false archived: false
) )
func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError> { func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) async throws -> [TwigsCore.Category] {
return Result.Publisher([MockCategoryRepository.category]).eraseToAnyPublisher() return [MockCategoryRepository.category]
} }
func getCategory(_ categoryId: String) -> AnyPublisher<Category, NetworkError> { func getCategory(_ categoryId: String) async throws -> TwigsCore.Category {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() return MockCategoryRepository.category
} }
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> { func createCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() return MockCategoryRepository.category
} }
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> { func updateCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() return MockCategoryRepository.category
} }
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> { func deleteCategory(_ id: String) async throws {
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
} }
} }

View file

@ -9,6 +9,25 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
extension Date {
static var firstOfMonth: Date {
get {
return Calendar.current.dateComponents([.calendar, .year,.month], from: Date()).date!
}
}
static let localeDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yyyyMMdd", options: 0, locale: Locale.current)
return dateFormatter
}()
func toLocaleString() -> String {
return Date.localeDateFormatter.string(from: self)
}
}
extension Int { extension Int {
func toDecimalString() -> String { func toDecimalString() -> String {
return String(format: "%.2f", Double(self) / 100.0) return String(format: "%.2f", Double(self) / 100.0)
@ -28,3 +47,18 @@ extension View {
return AnyView(self) return AnyView(self)
} }
} }
extension Array where Element: Identifiable {
mutating func remove(byId id: Element.ID) -> Element? {
if let index = firstIndex(where: { $0.id == id} ) {
return remove(at: index)
}
return nil
}
func filter(withoutId id: Element.ID) -> [Element] {
var updated = self
_ = updated.remove(byId: id)
return updated
}
}

View file

@ -13,20 +13,11 @@ struct LoginView: View {
@State var server: String = "" @State var server: String = ""
@State var username: String = "" @State var username: String = ""
@State var password: String = "" @State var password: String = ""
@EnvironmentObject var userData: AuthenticationDataStore @EnvironmentObject var dataStore: AuthenticationDataStore
var showLoader: Bool {
get {
if case self.userData.currentUser = Result<User, UserStatus>.failure(UserStatus.authenticating) {
return true
} else {
return false
}
}
}
var body: some View { var body: some View {
LoadingView( LoadingView(
isShowing: .constant(showLoader), isShowing: $dataStore.loading,
loadingText: "loading_login" loadingText: "loading_login"
) { ) {
NavigationView { NavigationView {
@ -44,11 +35,13 @@ struct LoginView: View {
.textContentType(UITextContentType.password) .textContentType(UITextContentType.password)
.textContentType(.password) .textContentType(.password)
Button("action_login", action: { Button("action_login", action: {
self.userData.login(server: self.server, username: self.username, password: self.password) Task {
try await self.dataStore.login(server: self.server, username: self.username, password: self.password)
}
}).buttonStyle(DefaultButtonStyle()) }).buttonStyle(DefaultButtonStyle())
Spacer() Spacer()
Text("info_register") Text("info_register")
NavigationLink(destination: RegistrationView(self.userData)) { NavigationLink(destination: RegistrationView(server: self.$server)) {
Text("action_register") Text("action_register")
.buttonStyle(DefaultButtonStyle()) .buttonStyle(DefaultButtonStyle())
} }

View file

@ -1,466 +0,0 @@
//
// BudgetApiService.swift
// Budget
//
// Created by Billy Brawner on 9/25/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
class TwigsApiService: BudgetRepository, CategoryRepository, RecurringTransactionsRepository, TransactionRepository, UserRepository {
let requestHelper: RequestHelper
convenience init() {
self.init(RequestHelper())
}
init(_ requestHelper: RequestHelper) {
self.requestHelper = requestHelper
}
func setToken(_ token: String) {
requestHelper.token = token
}
func setServer(_ server: String) {
var correctServer = server
if !server.starts(with: "http://") && !server.starts(with: "https://") {
correctServer = "http://\(correctServer)"
}
requestHelper.baseUrl = correctServer
}
// MARK: Budgets
func getBudgets(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Budget], NetworkError> {
var queries = [String: Array<String>]()
if count != nil {
queries["count"] = [String(count!)]
}
if (page != nil) {
queries["page"] = [String(page!)]
}
return requestHelper.get("/api/budgets", queries: queries)
}
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.get("/api/budgets/\(id)")
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.post("/api/budgets", data: budget, type: Budget.self)
}
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.put("/api/budgets/\(budget.id)", data: budget)
}
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError> {
return requestHelper.delete("/api/budgets/\(id)")
}
// MARK: Transactions
func getTransactions(
budgetIds: [String],
categoryIds: [String]? = nil,
from: Date? = nil,
to: Date? = nil,
count: Int? = nil,
page: Int? = nil
) -> AnyPublisher<[Transaction], NetworkError> {
var queries = [String: Array<String>]()
queries["budgetIds"] = budgetIds
if categoryIds != nil {
queries["categoryIds"] = categoryIds!
}
if from != nil {
queries["from"] = [from!.toISO8601String()]
}
if to != nil {
queries["to"] = [to!.toISO8601String()]
}
if count != nil {
queries["count"] = [String(count!)]
}
if (page != nil) {
queries["page"] = [String(page!)]
}
return requestHelper.get("/api/transactions", queries: queries)
}
func getTransaction(_ id: String) -> AnyPublisher<Transaction, NetworkError> {
return requestHelper.get("/api/transactions/\(id)")
}
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
return requestHelper.post("/api/transactions", data: transaction, type: Transaction.self)
}
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
return requestHelper.put("/api/transactions/\(transaction.id)", data: transaction)
}
func deleteTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
return requestHelper.delete("/api/transactions/\(id)")
}
func sumTransactions(budgetId: String? = nil, categoryId: String? = nil, from: Date? = nil, to: Date? = nil) -> AnyPublisher<BalanceResponse, NetworkError> {
var queries = [String: Array<String>]()
if let budgetId = budgetId {
queries["budgetId"] = [budgetId]
}
if let categoryId = categoryId {
queries["categoryId"] = [categoryId]
}
if let from = from {
queries["from"] = [from.toISO8601String()]
}
if let to = to {
queries["to"] = [to.toISO8601String()]
}
return requestHelper.get("/api/transactions/sum", queries: queries)
}
// MARK: Categories
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Category], NetworkError> {
var queries = [String: Array<String>]()
if budgetId != nil {
queries["budgetIds"] = [String(budgetId!)]
}
if expense != nil {
queries["expense"] = [String(expense!)]
}
if archived != nil {
queries["archived"] = [String(archived!)]
}
if count != nil {
queries["count"] = [String(count!)]
}
if (page != nil) {
queries["page"] = [String(page!)]
}
return requestHelper.get("/api/categories", queries: queries)
}
func getCategory(_ id: String) -> AnyPublisher<Category, NetworkError> {
return requestHelper.get("/api/categories/\(id)")
}
func getCategoryBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
return requestHelper.get("/api/categories/\(id)/balance")
}
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return requestHelper.post("/api/categories", data: category, type: Category.self)
}
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return requestHelper.put("/api/categories/\(category.id)", data: category)
}
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
return requestHelper.delete("/api/categories/\(id)")
}
// MARK: Users
func login(username: String, password: String) -> AnyPublisher<LoginResponse, NetworkError> {
return requestHelper.post(
"/api/users/login",
data: LoginRequest(username: username, password: password),
type: LoginResponse.self
).map { (session) -> LoginResponse in
return session
}.eraseToAnyPublisher()
}
func register(username: String, email: String, password: String) -> AnyPublisher<User, NetworkError> {
return requestHelper.post(
"/api/users/register",
data: RegistrationRequest(username: username, email: email, password: password),
type: User.self
).map { (user) -> User in
// Persist the credentials on sucessful registration
return user
}.eraseToAnyPublisher()
}
func getUser(_ id: String) -> AnyPublisher<User, NetworkError> {
return requestHelper.get("/api/users/\(id)")
}
func searchUsers(_ query: String) -> AnyPublisher<[User], NetworkError> {
return requestHelper.get(
"/api/users/search",
queries: ["query": [query]]
)
}
func getUsers(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[User], NetworkError> {
var queries = [String: Array<String>]()
if count != nil {
queries["count"] = [String(count!)]
}
if (page != nil) {
queries["page"] = [String(page!)]
}
return requestHelper.get("/api/Users", queries: queries)
}
func newUser(_ user: User) -> AnyPublisher<User, NetworkError> {
return requestHelper.post("/api/users", data: user, type: User.self)
}
func updateUser(_ user: User) -> AnyPublisher<User, NetworkError> {
return requestHelper.put("/api/users/\(user.id)", data: user)
}
func deleteUser(_ user: User) -> AnyPublisher<Empty, NetworkError> {
return requestHelper.delete("/api/users/\(user.id)")
}
// MARK: Recurring Transactions
func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> {
return requestHelper.get("/api/recurringtransactions", queries: ["budgetId": [budgetId]])
}
func getRecurringTransaction(_ id: String) -> AnyPublisher<RecurringTransaction, NetworkError> {
return requestHelper.get("/api/recurringtransactions/\(id)")
}
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
return requestHelper.post("/api/recurringtransactions", data: transaction, type: RecurringTransaction.self)
}
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
return requestHelper.put("/api/recurringtransactions/\(transaction.id)", data: transaction)
}
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
return requestHelper.delete("/api/recurringtransactions/\(id)")
}
}
private let BASE_URL = "BASE_URL"
class RequestHelper {
let decoder = JSONDecoder()
var baseUrl: String = UserDefaults.standard.string(forKey: BASE_URL) ?? "" {
didSet {
UserDefaults.standard.set(baseUrl, forKey: BASE_URL)
}
}
var token: String?
init() {
self.decoder.dateDecodingStrategy = .formatted(Date.iso8601DateFormatter)
}
func get<ResultType: Codable>(
_ endPoint: String,
queries: [String: Array<String>]? = nil
) -> AnyPublisher<ResultType, NetworkError> {
var combinedEndPoint = endPoint
if (queries != nil) {
for (key, values) in queries! {
for value in values {
let separator = combinedEndPoint.contains("?") ? "&" : "?"
combinedEndPoint += separator + key + "=" + value
}
}
}
return buildRequest(endPoint: combinedEndPoint, method: "GET")
}
func post<ResultType: Codable>(
_ endPoint: String,
data: Codable,
type: ResultType.Type
) -> AnyPublisher<ResultType, NetworkError> {
return buildRequest(
endPoint: endPoint,
method: "POST",
data: data
)
}
func put<ResultType: Codable>(
_ endPoint: String,
data: ResultType
) -> AnyPublisher<ResultType, NetworkError> {
return buildRequest(
endPoint: endPoint,
method: "PUT",
data: data
)
}
func delete(_ endPoint: String) -> AnyPublisher<Empty, NetworkError> {
// Delete requests return no body so they need a special request helper
guard let url = URL(string: self.baseUrl + endPoint) else {
return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher()
}
var request = URLRequest(url: url)
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = "DELETE"
let task = URLSession.shared.dataTaskPublisher(for: request)
.tryMap { (_, res) -> Empty in
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
switch (res as? HTTPURLResponse)?.statusCode {
case 400: throw NetworkError.badRequest
case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound
default: throw NetworkError.unknown
}
}
return Empty()
}
.mapError {
return NetworkError.jsonParsingFailed($0)
}
return task.eraseToAnyPublisher()
}
private func buildRequest<ResultType: Codable>(
endPoint: String,
method: String,
data: Encodable? = nil
) -> AnyPublisher<ResultType, NetworkError> {
guard let url = URL(string: self.baseUrl + endPoint) else {
print("Unable to build url from base: \(self.baseUrl)")
return Result.Publisher(.failure(.invalidUrl)).eraseToAnyPublisher()
}
print("\(method) - \(url)")
var request = URLRequest(url: url)
request.httpBody = data?.toJSONData()
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpMethod = method
if let token = self.token {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
return URLSession.shared.dataTaskPublisher(for: request)
.tryMap { (data, res) -> Data in
guard let response = res as? HTTPURLResponse, 200...299 ~= response.statusCode else {
switch (res as? HTTPURLResponse)?.statusCode {
case 400: throw NetworkError.badRequest
case 401, 403: throw NetworkError.unauthorized
case 404: throw NetworkError.notFound
default: throw NetworkError.unknown
}
}
// print(String(data: data, encoding: String.Encoding.utf8))
return data
}
.decode(type: ResultType.self, decoder: self.decoder)
.mapError {
return NetworkError.jsonParsingFailed($0)
}
.eraseToAnyPublisher()
}
}
struct Empty: Codable {}
enum NetworkError: Error, Equatable {
static func == (lhs: NetworkError, rhs: NetworkError) -> Bool {
switch (lhs, rhs) {
case (.loading, .loading):
return true
case (.unknown, .unknown):
return true
case (.notFound, .notFound):
return true
case (.unauthorized, .unauthorized):
return true
case (.badRequest, .badRequest):
return true
case (.invalidUrl, .invalidUrl):
return true
case (let .jsonParsingFailed(error1), let .jsonParsingFailed(error2)):
return error1.localizedDescription == error2.localizedDescription
default:
return false
}
}
var name: String {
get {
switch self {
case .loading:
return "loading"
case .unknown:
return "unknown"
case .notFound:
return "notFound"
case .deleted:
return "deleted"
case .unauthorized:
return "unauthorized"
case .badRequest:
return "badRequest"
case .invalidUrl:
return "invalidUrl"
case .jsonParsingFailed(_):
return "jsonParsingFailed"
}
}
}
case loading
case unknown
case notFound
case deleted
case unauthorized
case badRequest
case invalidUrl
case jsonParsingFailed(Error)
}
extension Encodable {
func toJSONData() -> Data? {
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
return try? encoder.encode(self)
}
}
extension Date {
static let iso8601DateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'"
dateFormatter.timeZone = TimeZone(identifier: "UTC")
return dateFormatter
}()
static let localeDateFormatter: DateFormatter = {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale.current
dateFormatter.dateFormat = DateFormatter.dateFormat(fromTemplate: "yyyyMMdd", options: 0, locale: Locale.current)
return dateFormatter
}()
static var firstOfMonth: Date {
get {
return Calendar.current.dateComponents([.calendar, .year,.month], from: Date()).date!
}
}
func toISO8601String() -> String {
return Date.iso8601DateFormatter.string(from: self)
}
func toLocaleString() -> String {
return Date.localeDateFormatter.string(from: self)
}
}

View file

@ -7,47 +7,71 @@
// //
import Foundation import Foundation
import Combine import TwigsCore
class TwigsInMemoryCacheService: TwigsApiService { class TwigsInMemoryCacheService: TwigsApiService {
var budgets = Set<Budget>() private var budgets = Set<Budget>()
var categories = Set<Category>() private var categories = Set<TwigsCore.Category>()
var transactions = Set<Transaction>() private var transactions = Set<Transaction>()
public init() {
super.init(RequestHelper())
}
// MARK: Budgets // MARK: Budgets
override func getBudgets(count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Budget], NetworkError> { override func getBudgets(count: Int? = nil, page: Int? = nil) async throws -> [Budget] {
let results = budgets.sorted { (first, second) -> Bool in let results = budgets.sorted { (first, second) -> Bool in
return first.name < second.name return first.name < second.name
} }
if results.isEmpty { if results.isEmpty {
return super.getBudgets(count: count, page: page).map { (budgets: [Budget]) in let budgets = try await super.getBudgets(count: count, page: page)
self.addBudgets(budgets) self.addBudgets(budgets)
return budgets return budgets
}.eraseToAnyPublisher()
} }
return Result.Publisher(.success(results.slice(count: count, page: page))).eraseToAnyPublisher() return results.slice(count: count, page: page)
} }
override func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> { override func getBudget(_ id: String) async throws -> Budget {
guard let budget = budgets.first(where: { $0.id == id }) else { guard let budget = budgets.first(where: { $0.id == id }) else {
return super.getBudget(id).map { budget in let budget = try await super.getBudget(id)
self.addBudget(budget) self.addBudget(budget)
return budget return budget
}.eraseToAnyPublisher()
} }
return Result.Publisher(.success(budget)).eraseToAnyPublisher() return budget
} }
func addBudgets(_ budgets: [Budget]) { override func newBudget(_ budget: Budget) async throws -> Budget {
let newBudget = try await super.newBudget(budget)
self.addBudget(newBudget)
return newBudget
}
override func updateBudget(_ budget: Budget) async throws -> Budget {
let newBudget = try await super.updateBudget(budget)
if let index = self.budgets.firstIndex(where: {$0.id == budget.id}) {
self.budgets.remove(at: index)
}
self.addBudget(newBudget)
return newBudget
}
override func deleteBudget(_ id: String) async throws {
try await super.deleteBudget(id)
if let index = self.budgets.firstIndex(where: {$0.id == id}) {
self.budgets.remove(at: index)
}
}
private func addBudgets(_ budgets: [Budget]) {
budgets.forEach { addBudget($0) } budgets.forEach { addBudget($0) }
} }
func addBudget(_ budget: Budget) { private func addBudget(_ budget: Budget) {
self.budgets.insert(budget) self.budgets.insert(budget)
} }
// MARK: Categories // MARK: Categories
override func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Category], NetworkError> { override func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) async throws -> [TwigsCore.Category] {
var results = categories var results = categories
if budgetId != nil { if budgetId != nil {
results = categories.filter { $0.budgetId == budgetId } results = categories.filter { $0.budgetId == budgetId }
@ -59,56 +83,50 @@ class TwigsInMemoryCacheService: TwigsApiService {
results = results.filter { $0.archived == archived } results = results.filter { $0.archived == archived }
} }
if results.isEmpty { if results.isEmpty {
return super.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page).map { (categories: [Category]) in let categories = try await super.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page)
self.addCategories(categories) self.addCategories(categories)
return categories return categories
}.eraseToAnyPublisher()
} }
let sortedResults = results.sorted { $0.title < $1.title } let sortedResults = results.sorted { $0.title < $1.title }
return Result.Publisher(.success(sortedResults.slice(count: count, page: page))).eraseToAnyPublisher() return sortedResults.slice(count: count, page: page)
} }
override func getCategory(_ id: String) -> AnyPublisher<Category, NetworkError> { override func getCategory(_ id: String) async throws -> TwigsCore.Category {
guard let category = categories.first(where: { $0.id == id }) else { guard let category = categories.first(where: { $0.id == id }) else {
return super.getCategory(id).map { category in let category = try await super.getCategory(id)
self.addCategory(category) self.addCategory(category)
return category return category
}.eraseToAnyPublisher()
} }
return Result.Publisher(.success(category)).eraseToAnyPublisher() return category
} }
func addCategories(_ categories: [Category]) { private func addCategories(_ categories: [TwigsCore.Category]) {
categories.forEach { addCategory($0) } categories.forEach { addCategory($0) }
} }
func addCategory(_ category: Category) { private func addCategory(_ category: TwigsCore.Category) {
self.categories.insert(category) self.categories.insert(category)
} }
override func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> { override func createCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
return super.createCategory(category).map { let newCategory = try await super.createCategory(category)
self.categories.insert(category) self.categories.insert(newCategory)
return $0 return newCategory
}.eraseToAnyPublisher()
} }
override func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> { override func updateCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
return super.updateCategory(category).map { let newCategory = try await super.updateCategory(category)
self.removeCategory(category.id) self.removeCategory(newCategory.id)
self.categories.insert(category) self.categories.insert(newCategory)
return $0 return newCategory
}.eraseToAnyPublisher()
} }
override func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> { override func deleteCategory(_ id: String) async throws {
return super.deleteCategory(id).map { try await super.deleteCategory(id)
self.removeCategory(id) self.removeCategory(id)
return $0
}.eraseToAnyPublisher()
} }
func removeCategory(_ id: String) { private func removeCategory(_ id: String) {
if let index = self.categories.firstIndex(where: { $0.id == id }) { if let index = self.categories.firstIndex(where: { $0.id == id }) {
self.categories.remove(at: index) self.categories.remove(at: index)
} }

View file

@ -1,24 +0,0 @@
//
// Observable.swift
// Budget
//
// Created by Billy Brawner on 10/11/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
import Combine
class Observable<T>: ObservableObject, Identifiable {
let id = UUID()
let objectWillChange = ObservableObjectPublisher()
let publisher = PassthroughSubject<T, Never>()
var value: T {
didSet {
objectWillChange.send()
publisher.send(value)
}
}
init(_ initValue: T) { self.value = initValue }
}

View file

@ -7,15 +7,11 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct ProfileView: View { struct ProfileView: View {
@EnvironmentObject var authDataStore: AuthenticationDataStore @EnvironmentObject var authDataStore: AuthenticationDataStore
var currentUser: User {
get {
return try! authDataStore.currentUser.get()
}
}
var body: some View { var body: some View {
VStack(spacing: 10) { VStack(spacing: 10) {
Image(systemName: "person.circle.fill") Image(systemName: "person.circle.fill")
@ -25,7 +21,7 @@ struct ProfileView: View {
.clipShape(Circle()) .clipShape(Circle())
.overlay(Circle().stroke(Color.white, lineWidth: 4)) .overlay(Circle().stroke(Color.white, lineWidth: 4))
.shadow(radius: 5) .shadow(radius: 5)
Text(currentUser.username) Text(authDataStore.currentUser!.username)
NavigationLink(destination: EmptyView()) { NavigationLink(destination: EmptyView()) {
Text("change_password") Text("change_password")
} }

View file

@ -1,276 +0,0 @@
//
// RecurringTransaction.swift
// Twigs
//
// Created by William Brawner on 12/3/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import Foundation
import SwiftUI
struct RecurringTransaction: Identifiable, Hashable, Codable {
let id: String
let title: String
let description: String?
let frequency: Frequency
let start: Date
let end: Date?
let amount: Int
let categoryId: String?
let expense: Bool
let createdBy: String
let budgetId: String
}
struct Frequency: Hashable, Codable, CustomStringConvertible {
let unit: FrequencyUnit
let count: Int
let time: Time
init?(unit: FrequencyUnit, count: Int, time: Time) {
if count < 1 {
return nil
}
self.unit = unit
self.count = count
self.time = time
}
init?(from string: String) {
let parts = string.split(separator: ";")
guard let count = Int(parts[1]) else {
return nil
}
var timeIndex = 3
switch parts[0] {
case "D":
self.unit = .daily
timeIndex = 2
case "W":
let daysOfWeek = parts[2].split(separator: ",").compactMap { dayOfWeek in
DayOfWeek(rawValue: String(dayOfWeek))
}
if daysOfWeek.isEmpty {
return nil
}
self.unit = .weekly(Set(daysOfWeek))
case "M":
guard let dayOfMonth = DayOfMonth(from: String(parts[2])) else {
return nil
}
self.unit = .monthly(dayOfMonth)
case "Y":
guard let dayOfYear = DayOfYear(from: String(parts[2])) else {
return nil
}
self.unit = .yearly(dayOfYear)
default:
return nil
}
guard let time = Time(from: String(parts[timeIndex])) else {
return nil
}
self.time = time
self.count = count
}
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let frequencyString = try container.decode(String.self)
self.init(from: frequencyString)!
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(description)
}
var description: String {
// TODO: Make the backend representation of this more sensible and then use this
// return [unit.description, count.description, time.description].joined(separator: ";")
let unitParts = "\(unit)".split(separator: ";")
if unitParts.count == 1 {
return [unitParts[0].description, count.description, time.description].joined(separator: ";")
} else{
return [unitParts[0].description, count.description, unitParts[1].description, time.description].joined(separator: ";")
}
}
var naturalDescription: String {
return unit.format(count: count, time: time)
}
}
enum FrequencyUnit: Hashable, CustomStringConvertible {
case daily
case weekly(Set<DayOfWeek>)
case monthly(DayOfMonth)
case yearly(DayOfYear)
var description: String {
switch self {
case .daily:
return "D"
case .weekly(let daysOfWeek):
return String(format: "W;%s", daysOfWeek.map { $0.rawValue }.joined(separator: ","))
case .monthly(let dayOfMonth):
return String(format: "M;%s", dayOfMonth.description)
case .yearly(let dayOfYear):
return String(format: "Y;%s", dayOfYear.description)
}
}
func format(count: Int, time: Time) -> String {
switch self {
case .daily:
return String(localized: "Every \(count) day(s) at \(time.description)")
case .weekly(let daysOfWeek):
return String(localized: "Every \(count) week(s) on \(daysOfWeek.description) at \(time.description)")
case .monthly(let dayOfMonth):
return String(localized: "Every \(count) month(s) on the \(dayOfMonth.description) at \(time.description)")
case .yearly(let dayOfYear):
return String(localized: "Every \(count) year(s) on \(dayOfYear.description) at \(time.description)")
}
}
}
struct Time: Hashable, CustomStringConvertible {
let hours: Int
let minutes: Int
let seconds: Int
init?(hours: Int, minutes: Int, seconds: Int) {
if hours < 0 || hours > 23 {
return nil
}
if minutes < 0 || minutes > 59 {
return nil
}
if seconds < 0 || seconds > 59 {
return nil
}
self.hours = hours
self.minutes = minutes
self.seconds = seconds
}
init?(from string: String) {
let parts = string.split(separator: ":").compactMap {
Int($0)
}
if parts.count != 3 {
return nil
}
self.init(hours: parts[0], minutes: parts[1], seconds: parts[2])
}
var description: String {
return String(format: "%02d:%02d:%02d", hours, minutes, seconds)
}
}
enum DayOfMonth: Hashable, CustomStringConvertible {
case positional(Position, DayOfWeek)
case fixed(Int)
init?(position: Position, dayOfWeek: DayOfWeek) {
if position == .day {
return nil
}
self = .positional(position, dayOfWeek)
}
init?(day: Int) {
if day < 1 || day > 31 {
return nil
}
self = .fixed(day)
}
init?(from string: String) {
let parts = string.split(separator: "-")
guard let position = Position.init(rawValue: String(parts[0])) else {
return nil
}
if position == .day {
guard let day = Int(parts[1]) else {
return nil
}
self = .fixed(day)
} else {
guard let dayOfWeek = DayOfWeek(rawValue: String(parts[1])) else {
return nil
}
self = .positional(position, dayOfWeek)
}
}
var description: String {
switch self {
case .positional(let position, let dayOfWeek):
return "\(position)-\(dayOfWeek)"
case .fixed(let day):
return "\(Position.day)-\(day)"
}
}
}
enum Position: String, Hashable {
case day = "DAY"
case first = "FIRST"
case second = "SECOND"
case third = "THIRD"
case fourth = "FOURTH"
case last = "LAST"
}
enum DayOfWeek: String, Hashable {
case monday = "MONDAY"
case tuesday = "TUESDAY"
case wednesday = "WEDNESDAY"
case thursday = "THURSDAY"
case friday = "FRIDAY"
case saturday = "SATURDAY"
case sunday = "SUNDAY"
}
struct DayOfYear: Hashable, CustomStringConvertible {
let month: Int
let day: Int
init?(month: Int, day: Int) {
var maxDay: Int
switch month {
case 2:
maxDay = 29;
break;
case 4, 6, 9, 11:
maxDay = 30;
break;
default:
maxDay = 31;
}
if day < 1 || day > maxDay {
return nil
}
if month < 1 || month > 12 {
return nil
}
self.day = day
self.month = month
}
init?(from string: String) {
let parts = string.split(separator: "-").compactMap {
Int($0)
}
if parts.count < 2 {
return nil
}
self.init(month: parts[0], day: parts[1])
}
var description: String {
return String(format: "%02d-%02d", self.month, self.day)
}
}

View file

@ -9,103 +9,61 @@
import Foundation import Foundation
import Combine import Combine
import Collections import Collections
import TwigsCore
class RecurringTransactionDataStore: ObservableObject { class RecurringTransactionDataStore: AsyncObservableObject {
private let repository: RecurringTransactionsRepository private let repository: RecurringTransactionsRepository
private var currentRequest: AnyCancellable? = nil @Published var transactions: AsyncData<[RecurringTransaction]> = .empty
@Published var transactions: Result<[RecurringTransaction], NetworkError>? = nil @Published var transaction: AsyncData<RecurringTransaction> = .empty
@Published var transaction: Result<RecurringTransaction, NetworkError>? = nil
init(_ repository: RecurringTransactionsRepository, budgetId: String) { init(_ repository: RecurringTransactionsRepository) {
self.repository = repository self.repository = repository
getRecurringTransactions(budgetId)
} }
func getRecurringTransactions(_ budgetId: String) { func getRecurringTransactions(_ budgetId: String) async {
self.transactions = .failure(.loading) self.transactions = .loading
self.currentRequest = self.repository.getRecurringTransactions(budgetId: budgetId) do {
.receive(on: DispatchQueue.main) let transactions = try await self.repository.getRecurringTransactions(budgetId)
.sink(receiveCompletion: { (completion) in self.transactions = .success(transactions.sorted(by: { $0.title < $1.title }))
switch completion { } catch {
case .finished: self.transactions = .error(error)
self.currentRequest = nil
return
case .failure(let error):
print("Error loading recurring transactions: \(error.name)")
self.transactions = .failure(error)
}
}, receiveValue: { (transactions) in
self.transactions = .success(transactions.sorted(by: { $0.title < $1.title }))
})
}
func getRecurringTransaction(_ id: String) {
self.transaction = .failure(.loading)
self.currentRequest = self.repository.getRecurringTransaction(id)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.transaction = .failure(error)
}
}, receiveValue: { (transaction) in
self.transaction = .success(transaction)
})
}
func saveRecurringTransaction(_ transaction: RecurringTransaction) {
self.transaction = .failure(.loading)
var transactionSavePublisher: AnyPublisher<RecurringTransaction, NetworkError>
if (transaction.id != "") {
transactionSavePublisher = self.repository.updateRecurringTransaction(transaction)
} else {
transactionSavePublisher = self.repository.createRecurringTransaction(transaction)
} }
self.currentRequest = transactionSavePublisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.transaction = .failure(error)
}
}, receiveValue: { (transaction) in
self.transaction = .success(transaction)
if case var .success(transactions) = self.transactions {
transactions.insert(transaction, at: 0)
self.transactions = .success(transactions)
}
})
} }
func deleteRecurringTransaction(_ id: String) { func saveRecurringTransaction(_ transaction: RecurringTransaction) async {
self.transaction = .failure(.loading) self.transaction = .loading
do {
self.currentRequest = self.repository.deleteRecurringTransaction(id) var savedTransaction: RecurringTransaction
.receive(on: DispatchQueue.main) if (transaction.id != "") {
.sink(receiveCompletion: { completion in savedTransaction = try await self.repository.updateRecurringTransaction(transaction)
switch completion { } else {
case .finished: savedTransaction = try await self.repository.createRecurringTransaction(transaction)
self.currentRequest = nil }
return self.transaction = .success(savedTransaction)
case .failure(let error): if case var .success(transactions) = self.transactions {
self.transaction = .failure(error) transactions = transactions.filter(withoutId: savedTransaction.id)
} transactions.append(savedTransaction)
}, receiveValue: { (empty) in self.transactions = .success(transactions.sorted(by: { $0.title < $1.title }))
self.transaction = .failure(.deleted) }
if case let .success(transactions) = self.transactions { } catch {
self.transactions = .success(transactions.filter { $0.id != id }) self.transactions = .error(error)
} }
}) }
func deleteRecurringTransaction(_ transaction: RecurringTransaction) async {
self.transactions = .loading
do {
try await self.repository.deleteRecurringTransaction(transaction.id)
self.transaction = .empty
if case let .success(transactions) = self.transactions {
self.transactions = .success(transactions.filter(withoutId: transaction.id))
}
} catch {
self.transaction = .error(error, transaction)
}
} }
func clearSelectedRecurringTransaction() { func clearSelectedRecurringTransaction() {
self.transaction = nil self.transaction = .empty
} }
} }

View file

@ -7,6 +7,7 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct RecurringTransactionDetailsView: View { struct RecurringTransactionDetailsView: View {
let transaction: RecurringTransaction let transaction: RecurringTransaction

View file

@ -7,20 +7,24 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct RecurringTransactionsListView: View { struct RecurringTransactionsListView: View {
@ObservedObject var dataStore: RecurringTransactionDataStore @ObservedObject var dataStore: RecurringTransactionDataStore
let budget: Budget
var body: some View { var body: some View {
switch dataStore.transactions { InlineLoadingView(
case .success(let transactions): action: {
return try await self.dataStore.getRecurringTransactions(self.budget.id)
},
errorTextLocalizedStringKey: "Failed to load recurring transactions"
) { (transactions: [RecurringTransaction]) in
List { List {
ForEach(transactions) { transaction in ForEach(transactions) { transaction in
RecurringTransactionsListItemView(transaction) RecurringTransactionsListItemView(transaction)
} }
} }
default:
ActivityIndicator(isAnimating: .constant(true), style: .medium)
} }
} }
} }
@ -28,7 +32,7 @@ struct RecurringTransactionsListView: View {
#if DEBUG #if DEBUG
struct RecurringTransactionView_Previews: PreviewProvider { struct RecurringTransactionView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(MockRecurringTransactionRepository(), budgetId: "")) RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(MockRecurringTransactionRepository()), budget: MockBudgetRepository.budget)
} }
} }
#endif #endif

View file

@ -8,14 +8,7 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
protocol RecurringTransactionsRepository {
func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError>
func getRecurringTransaction(_ id: String) -> AnyPublisher<RecurringTransaction, NetworkError>
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError>
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError>
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError>
}
#if DEBUG #if DEBUG
class MockRecurringTransactionRepository: RecurringTransactionsRepository { class MockRecurringTransactionRepository: RecurringTransactionsRepository {
@ -33,24 +26,23 @@ class MockRecurringTransactionRepository: RecurringTransactionsRepository {
budgetId: MockBudgetRepository.budget.id budgetId: MockBudgetRepository.budget.id
) )
func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> { func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction] {
return Result.Publisher([MockRecurringTransactionRepository.transaction]).eraseToAnyPublisher() return [MockRecurringTransactionRepository.transaction]
} }
func getRecurringTransaction(_ id: String) -> AnyPublisher<RecurringTransaction, NetworkError> { func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction {
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() return MockRecurringTransactionRepository.transaction
} }
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> { func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() return MockRecurringTransactionRepository.transaction
} }
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> { func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() return MockRecurringTransactionRepository.transaction
} }
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> { func deleteRecurringTransaction(_ id: String) async throws {
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
} }
} }
#endif #endif

View file

@ -9,14 +9,18 @@
import SwiftUI import SwiftUI
struct RegistrationView: View { struct RegistrationView: View {
@Binding var server: String
@State var username: String = "" @State var username: String = ""
@State var email: String = "" @State var email: String = ""
@State var password: String = "" @State var password: String = ""
@State var confirmedPassword: String = "" @State var confirmedPassword: String = ""
@ObservedObject var userData: AuthenticationDataStore @EnvironmentObject var dataStore: AuthenticationDataStore
var body: some View { var body: some View {
VStack { VStack {
TextField(LocalizedStringKey("prompt_server"), text: self.$server)
.textFieldStyle(RoundedBorderTextFieldStyle())
.textContentType(.URL)
TextField("prompt_username", text: self.$username) TextField("prompt_username", text: self.$username)
.autocapitalization(UITextAutocapitalizationType.none) .autocapitalization(UITextAutocapitalizationType.none)
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
@ -32,19 +36,18 @@ struct RegistrationView: View {
.textFieldStyle(RoundedBorderTextFieldStyle()) .textFieldStyle(RoundedBorderTextFieldStyle())
.textContentType(UITextContentType.newPassword) .textContentType(UITextContentType.newPassword)
Button("action_register", action: { Button("action_register", action: {
self.userData.register( Task {
username: self.username, try await self.dataStore.register(
email: self.email, server: self.server,
password: self.password, username: self.username,
confirmPassword: self.confirmedPassword email: self.email,
) password: self.password,
confirmPassword: self.confirmedPassword
)
}
}).buttonStyle(DefaultButtonStyle()) }).buttonStyle(DefaultButtonStyle())
}.padding() }.padding()
} }
init(_ userData: AuthenticationDataStore) {
self.userData = userData
}
} }
//struct RegistrationView_Previews: PreviewProvider { //struct RegistrationView_Previews: PreviewProvider {

View file

@ -7,24 +7,20 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct SidebarBudgetView: View { struct SidebarBudgetView: View {
@EnvironmentObject var authenticationDataStore: AuthenticationDataStore @EnvironmentObject var authenticationDataStore: AuthenticationDataStore
@StateObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore
let apiService: TwigsApiService let apiService: TwigsApiService
@State var isSelectingBudget = true @State var isSelectingBudget = true
@State var hasSelectedBudget = false @State var hasSelectedBudget = false
@State var isAddingTransaction = false @State var isAddingTransaction = false
@State var tabSelection: Int? = 0 @State var tabSelection: Int? = 0
init(_ apiService: TwigsApiService) {
self.apiService = apiService
self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService))
}
@ViewBuilder @ViewBuilder
var mainView: some View { var mainView: some View {
if case let .success(budget) = budgetDataStore.budget { if case let .success(budget) = self.budgetDataStore.budget {
NavigationView { NavigationView {
List { List {
NavigationLink( NavigationLink(
@ -38,7 +34,7 @@ struct SidebarBudgetView: View {
NavigationLink( NavigationLink(
tag: 1, tag: 1,
selection: $tabSelection, selection: $tabSelection,
destination: { TransactionListView(budget).navigationBarTitle("transactions") }, destination: { TransactionListView<EmptyView>(budget).navigationBarTitle("transactions") },
label: { Label("transactions", systemImage: "dollarsign.circle") }) label: { Label("transactions", systemImage: "dollarsign.circle") })
.keyboardShortcut("2") .keyboardShortcut("2")
NavigationLink( NavigationLink(
@ -50,14 +46,15 @@ struct SidebarBudgetView: View {
NavigationLink( NavigationLink(
tag: 3, tag: 3,
selection: $tabSelection, selection: $tabSelection,
destination: { RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService, budgetId: budget.id)).navigationBarTitle("recurring_transactions") }, destination: { RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService), budget: budget).navigationBarTitle("recurring_transactions") },
label: { Label("recurring_transactions", systemImage: "arrow.triangle.2.circlepath") }) label: { Label("recurring_transactions", systemImage: "arrow.triangle.2.circlepath") })
.keyboardShortcut("4") .keyboardShortcut("4")
BudgetListsView() BudgetListsView()
} }
.navigationTitle(budget.name) .navigationTitle(budget.name)
}.environmentObject(TransactionDataStore(apiService)) }.navigationViewStyle(.columns)
.environmentObject(CategoryDataStore(apiService)) .environmentObject(TransactionDataStore(apiService))
.environmentObject(CategoryListDataStore(apiService))
.environmentObject(budgetDataStore) .environmentObject(budgetDataStore)
.environmentObject(UserDataStore(apiService)) .environmentObject(UserDataStore(apiService))
} else { } else {
@ -70,13 +67,17 @@ struct SidebarBudgetView: View {
mainView mainView
.sheet(isPresented: $authenticationDataStore.showLogin, .sheet(isPresented: $authenticationDataStore.showLogin,
onDismiss: { onDismiss: {
self.budgetDataStore.getBudgets() Task {
await self.budgetDataStore.getBudgets()
}
}, },
content: { content: {
LoginView() LoginView()
.environmentObject(authenticationDataStore) .environmentObject(authenticationDataStore)
.onDisappear { .onDisappear {
self.budgetDataStore.getBudgets() Task {
await self.budgetDataStore.getBudgets()
}
} }
}) })
.interactiveDismissDisabled(true) .interactiveDismissDisabled(true)

View file

@ -7,21 +7,18 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct TabbedBudgetView: View { struct TabbedBudgetView: View {
@EnvironmentObject var authDataStore: AuthenticationDataStore
@EnvironmentObject var authenticationDataStore: AuthenticationDataStore @EnvironmentObject var authenticationDataStore: AuthenticationDataStore
@StateObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore
let apiService: TwigsApiService let apiService: TwigsApiService
@State var isSelectingBudget = true @State var isSelectingBudget = true
@State var hasSelectedBudget = false @State var hasSelectedBudget = false
@State var isAddingTransaction = false @State var isAddingTransaction = false
@State var tabSelection: Int = 0 @State var tabSelection: Int = 0
init(_ apiService: TwigsApiService) {
self.apiService = apiService
self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService))
}
@ViewBuilder @ViewBuilder
var mainView: some View { var mainView: some View {
if case let .success(budget) = budgetDataStore.budget { if case let .success(budget) = budgetDataStore.budget {
@ -42,7 +39,7 @@ struct TabbedBudgetView: View {
.tag(0) .tag(0)
.keyboardShortcut("1") .keyboardShortcut("1")
NavigationView { NavigationView {
TransactionListView(budget) TransactionListView<EmptyView>(budget)
.sheet(isPresented: $isAddingTransaction, .sheet(isPresented: $isAddingTransaction,
onDismiss: { onDismiss: {
isAddingTransaction = false isAddingTransaction = false
@ -70,7 +67,7 @@ struct TabbedBudgetView: View {
.tag(2) .tag(2)
.keyboardShortcut("3") .keyboardShortcut("3")
NavigationView { NavigationView {
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService, budgetId: budget.id)) RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService), budget: budget)
.navigationBarTitle("recurring_transactions") .navigationBarTitle("recurring_transactions")
} }
.tabItem { .tabItem {
@ -80,7 +77,7 @@ struct TabbedBudgetView: View {
.tag(3) .tag(3)
.keyboardShortcut("4") .keyboardShortcut("4")
}.environmentObject(TransactionDataStore(apiService)) }.environmentObject(TransactionDataStore(apiService))
.environmentObject(CategoryDataStore(apiService)) .environmentObject(CategoryListDataStore(apiService))
.environmentObject(budgetDataStore) .environmentObject(budgetDataStore)
.environmentObject(UserDataStore(apiService)) .environmentObject(UserDataStore(apiService))
} else { } else {
@ -91,13 +88,17 @@ struct TabbedBudgetView: View {
var body: some View { var body: some View {
mainView.sheet(isPresented: $authenticationDataStore.showLogin, mainView.sheet(isPresented: $authenticationDataStore.showLogin,
onDismiss: { onDismiss: {
self.budgetDataStore.getBudgets() Task {
await self.budgetDataStore.getBudgets()
}
}, },
content: { content: {
LoginView() LoginView()
.environmentObject(authenticationDataStore) .environmentObject(authenticationDataStore)
.onDisappear { .onDisappear {
self.budgetDataStore.getBudgets() Task {
await self.budgetDataStore.getBudgets()
}
} }
}).sheet(isPresented: $budgetDataStore.showBudgetSelection, }).sheet(isPresented: $budgetDataStore.showBudgetSelection,
content: { content: {

View file

@ -8,11 +8,13 @@
import SwiftUI import SwiftUI
import Combine import Combine
import TwigsCore
struct AddTransactionView: View { struct AddTransactionView: View {
@Binding var showSheet: Bool @Binding var showSheet: Bool
@EnvironmentObject var authDataStore: AuthenticationDataStore @EnvironmentObject var authDataStore: AuthenticationDataStore
@EnvironmentObject var transactionDataStore: TransactionDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore
@State var loading: Bool = false
@State var title: String = "" @State var title: String = ""
@State var description: String = "" @State var description: String = ""
@State var date: Date = Date() @State var date: Date = Date()
@ -22,28 +24,29 @@ struct AddTransactionView: View {
@State var categoryId: String = "" @State var categoryId: String = ""
var createdBy: String { var createdBy: String {
get { get {
return try! authDataStore.currentUser.get().id return authDataStore.currentUser!.id
} }
} }
var stateContent: AnyView { @ViewBuilder
switch transactionDataStore.transaction { var stateContent: some View {
case .success(_): if let _ = transactionDataStore.transaction {
self.showSheet = false EmptyView().onAppear {
return AnyView(EmptyView()) self.showSheet = false
case .failure(.loading): }
return AnyView(EmbeddedLoadingView()) } else if loading {
default: EmbeddedLoadingView()
return AnyView(EditTransactionForm( } else {
title: self.$title, EditTransactionForm(
description: self.$description, title: self.$title,
date: self.$date, description: self.$description,
amount: self.$amount, date: self.$date,
type: self.$type, amount: self.$amount,
budgetId: self.$budgetId, type: self.$type,
categoryId: self.$categoryId, budgetId: self.$budgetId,
deleteAction: nil categoryId: self.$categoryId,
)) deleteAction: nil
)
} }
} }
@ -56,21 +59,25 @@ struct AddTransactionView: View {
}, },
trailing: Button("save") { trailing: Button("save") {
let amount = Double(self.amount) ?? 0.0 let amount = Double(self.amount) ?? 0.0
self.transactionDataStore.saveTransaction(Transaction( Task {
id: "", try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction(
title: self.title, id: "",
description: self.description, title: self.title,
date: self.date, description: self.description,
amount: Int(amount * 100.0), date: self.date,
categoryId: self.categoryId != "" ? self.categoryId : nil, amount: Int(amount * 100.0),
expense: self.type == TransactionType.expense, categoryId: self.categoryId != "" ? self.categoryId : nil,
createdBy: self.createdBy, expense: self.type == TransactionType.expense,
budgetId: self.budgetId createdBy: self.createdBy,
)) budgetId: self.budgetId
))
}
}) })
} }
.onDisappear { .onDisappear {
_ = self.transactionDataStore.getTransactions(self.budgetId, categoryId: self.categoryId) Task {
try await self.transactionDataStore.getTransactions(self.budgetId, categoryId: self.categoryId)
}
self.transactionDataStore.clearSelectedTransaction() self.transactionDataStore.clearSelectedTransaction()
self.title = "" self.title = ""
self.description = "" self.description = ""

View file

@ -7,8 +7,10 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct EditTransactionForm: View { struct EditTransactionForm: View {
@EnvironmentObject var authDataStore: AuthenticationDataStore
@Binding var title: String @Binding var title: String
@Binding var description: String @Binding var description: String
@Binding var date: Date @Binding var date: Date
@ -34,7 +36,7 @@ struct EditTransactionForm: View {
} }
} }
BudgetPicker(self.$budgetId) BudgetPicker(self.$budgetId)
CategoryPicker(self.$budgetId, categoryId: self.$categoryId, expense: self.$type) CategoryPicker(self.$budgetId, categoryId: self.$categoryId, expense: self.$type, apiService: self.authDataStore.apiService)
if deleteAction != nil { if deleteAction != nil {
Button(action: { Button(action: {
self.showingAlert = true self.showingAlert = true
@ -57,14 +59,13 @@ struct BudgetPicker: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch self.budgetsDataStore.budgets { if let budgets = self.budgetsDataStore.budgets {
case .success(let budgets):
Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) { Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) {
ForEach(budgets) { budget in ForEach(budgets) { budget in
Text(budget.name) Text(budget.name)
} }
} }
default: } else {
Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) { Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) {
Text("") Text("")
} }
@ -81,48 +82,32 @@ struct CategoryPicker: View {
let budgetId: Binding<String> let budgetId: Binding<String>
var categoryId: Binding<String> var categoryId: Binding<String>
let expense: Binding<TransactionType> let expense: Binding<TransactionType>
@State var requestId: String = ""
var isRequestIdValid: Bool {
get {
return self.requestId != ""
&& self.requestId.contains(budgetId.wrappedValue)
&& self.requestId.split(separator: "-")[1].contains(String(describing: self.expense.wrappedValue == TransactionType.expense))
}
}
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch self.categoryDataStore.categories[requestId] { if let categories = self.categoryDataStore.categories {
case .success(let categories):
Picker(LocalizedStringKey("prompt_category"), selection: self.categoryId) { Picker(LocalizedStringKey("prompt_category"), selection: self.categoryId) {
ForEach(categories) { category in ForEach(categories) { category in
Text(category.title) Text(category.title)
} }
}.onAppear {
if !self.isRequestIdValid {
self.requestId = categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, count: nil, page: nil)
}
} }
case .failure(.loading): } else {
VStack { VStack {
ActivityIndicator(isAnimating: .constant(true), style: .medium) ActivityIndicator(isAnimating: .constant(true), style: .medium)
}.onAppear { }.onAppear {
if budgetId.wrappedValue != "" { Task {
if !self.isRequestIdValid { try await self.categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, archived: false)
self.requestId = categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, count: nil, page: nil)
}
} }
} }
default:
EmptyView()
} }
} }
@EnvironmentObject var categoryDataStore: CategoryDataStore @StateObject var categoryDataStore: CategoryListDataStore
init(_ budgetId: Binding<String>, categoryId: Binding<String>, expense: Binding<TransactionType>) { init(_ budgetId: Binding<String>, categoryId: Binding<String>, expense: Binding<TransactionType>, apiService: TwigsApiService) {
self.budgetId = budgetId self.budgetId = budgetId
self.categoryId = categoryId self.categoryId = categoryId
self.expense = expense self.expense = expense
self._categoryDataStore = StateObject(wrappedValue: CategoryListDataStore(apiService))
} }
} }

View file

@ -8,27 +8,9 @@
import Foundation import Foundation
import SwiftUI import SwiftUI
import TwigsCore
struct Transaction: Identifiable, Hashable, Codable { extension TransactionType {
let id: String
let title: String
let description: String?
let date: Date
let amount: Int
let categoryId: String?
let expense: Bool
let createdBy: String
let budgetId: String
}
struct BalanceResponse: Codable {
let balance: Int
}
enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
case expense
case income
var localizedKey: LocalizedStringKey { var localizedKey: LocalizedStringKey {
var key: String var key: String
switch self { switch self {
@ -39,11 +21,9 @@ enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
} }
return LocalizedStringKey(key) return LocalizedStringKey(key)
} }
var id: TransactionType { self }
} }
extension Transaction { extension TwigsCore.Transaction {
var type: TransactionType { var type: TransactionType {
if (self.expense) { if (self.expense) {
return .expense return .expense

View file

@ -9,136 +9,50 @@
import Foundation import Foundation
import Combine import Combine
import Collections import Collections
import TwigsCore
class TransactionDataStore: ObservableObject { class TransactionDataStore: AsyncObservableObject {
private var currentRequest: AnyCancellable? = nil @Published var transactions: AsyncData<OrderedDictionary<String, [Transaction]>> = .empty
private var sumRequests: [String:AnyCancellable] = [:] @Published var transaction: AsyncData<Transaction> = .empty
@Published var transactions: [String:Result<OrderedDictionary<String, [Transaction]>, NetworkError>] = ["": .failure(.loading)]
@Published var transaction: Result<Transaction, NetworkError> = .failure(.unknown)
@Published var sums: [String:Result<BalanceResponse, NetworkError>] = ["": .failure(.loading)]
func getTransactions(_ budgetId: String, categoryId: String? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) -> String { func getTransactions(_ budgetId: String, categoryId: String? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) async {
let requestId = "\(budgetId)-\(categoryId ?? "all")" try await load {
self.transactions[requestId] = .failure(.loading) var categoryIds: [String] = []
if let categoryId = categoryId {
var categoryIds: [String] = [] categoryIds.append(categoryId)
if let categoryId = categoryId { }
categoryIds.append(categoryId) let transactions = try await self.transactionRepository.getTransactions(
budgetIds: [budgetId],
categoryIds: categoryIds,
from: from ?? Date.firstOfMonth,
to: nil,
count: count,
page: page
)
let groupedTransactions = OrderedDictionary<String,[Transaction]>(grouping: transactions, by: { $0.date.toLocaleString() })
self.transactions = groupedTransactions
} }
self.currentRequest = self.transactionRepository.getTransactions(
budgetIds: [budgetId],
categoryIds: categoryIds,
from: from ?? Date.firstOfMonth,
to: nil,
count: count,
page: page
)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
self.objectWillChange.send() // TODO: Remove this hack for updating dictionary values
return
case .failure(let error):
print("Error loading transactions: \(error.name)")
self.transactions[requestId] = .failure(error)
}
}, receiveValue: { (transactions) in
let groupedTransactions = OrderedDictionary<String,[Transaction]>(grouping: transactions, by: { $0.date.toLocaleString() })
self.transactions[requestId] = .success(groupedTransactions)
})
return requestId
} }
func getTransaction(_ transactionId: String) { func saveTransaction(_ transaction: Transaction) async {
self.transaction = .failure(.loading) try await load {
if (transaction.id != "") {
self.currentRequest = self.transactionRepository.getTransaction(transactionId) self.transaction = try await self.transactionRepository.updateTransaction(transaction)
.receive(on: DispatchQueue.main) } else {
.sink(receiveCompletion: { (completion) in self.transaction = try await self.transactionRepository.createTransaction(transaction)
switch completion { }
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.transaction = .failure(error)
}
}, receiveValue: { (transaction) in
self.transaction = .success(transaction)
})
}
func saveTransaction(_ transaction: Transaction) {
self.transaction = .failure(.loading)
var transactionSavePublisher: AnyPublisher<Transaction, NetworkError>
if (transaction.id != "") {
transactionSavePublisher = self.transactionRepository.updateTransaction(transaction)
} else {
transactionSavePublisher = self.transactionRepository.createTransaction(transaction)
} }
self.currentRequest = transactionSavePublisher
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (completion) in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.transaction = .failure(error)
}
}, receiveValue: { (transaction) in
self.transaction = .success(transaction)
self.transactions = ["": .failure(.loading)]
})
} }
func deleteTransaction(_ transactionId: String) { func deleteTransaction(_ transactionId: String) async {
self.transaction = .failure(.loading) try await load {
try await self.transactionRepository.deleteTransaction(transactionId)
self.currentRequest = self.transactionRepository.deleteTransaction(transactionId) self.transaction = nil
.receive(on: DispatchQueue.main) }
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.transaction = .failure(error)
}
}, receiveValue: { (empty) in
self.transaction = .failure(.deleted)
self.transactions = ["": .failure(.loading)]
})
}
func sum(budgetId: String? = nil, categoryId: String? = nil, from: Date? = nil, to: Date? = nil) -> String {
let sumId = "\(String(describing: budgetId)):\(String(describing: categoryId)):\(String(describing: from)):\(String(describing: to))"
self.sums[sumId] = .failure(.loading)
self.sumRequests[sumId] = self.transactionRepository.sumTransactions(budgetId: budgetId, categoryId: categoryId, from: from, to: to)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { completion in
switch completion {
case .finished:
self.sumRequests.removeValue(forKey: sumId)
return
case .failure(let error):
self.sums[sumId] = .failure(error)
}
}, receiveValue: { (sum) in
self.sums[sumId] = .success(sum)
})
return sumId
} }
func clearSelectedTransaction() { func clearSelectedTransaction() {
self.transaction = .failure(.unknown) self.transaction = nil
}
func reset() {
self.transaction = .failure(.unknown)
self.transactions = ["": .failure(.loading)]
} }
private let transactionRepository: TransactionRepository private let transactionRepository: TransactionRepository

View file

@ -7,49 +7,47 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct TransactionDetailsView: View { struct TransactionDetailsView: View {
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@EnvironmentObject var dataStore: TransactionDataStore
@State var shouldNavigateUp: Bool = false @State var shouldNavigateUp: Bool = false
let transaction: Transaction
var body: some View { var body: some View {
ScrollView { if let transaction = self.dataStore.transaction {
VStack(alignment: .leading) { ScrollView {
Text(transaction.title) VStack(alignment: .leading) {
.font(.title) Text(transaction.title)
Text(transaction.amount.toCurrencyString()) .font(.title)
.font(.headline) Text(transaction.amount.toCurrencyString())
.foregroundColor(transaction.expense ? .red : .green) .font(.headline)
.multilineTextAlignment(.trailing) .foregroundColor(transaction.expense ? .red : .green)
Spacer().frame(height: 10) .multilineTextAlignment(.trailing)
Text(transaction.date.toLocaleString()) Spacer().frame(height: 10)
.font(.subheadline) Text(transaction.date.toLocaleString())
.foregroundColor(.secondary) .font(.subheadline)
Spacer().frame(height: 20.0) .foregroundColor(.secondary)
LabeledField(label: "notes", value: transaction.description, showDivider: true) Spacer().frame(height: 20.0)
CategoryLineItem(transaction.categoryId) LabeledField(label: "notes", value: transaction.description, showDivider: true)
BudgetLineItem() CategoryLineItem(transaction.categoryId)
UserLineItem(transaction.createdBy) BudgetLineItem()
}.padding() UserLineItem(transaction.createdBy)
}.padding()
}
.navigationBarItems(trailing: NavigationLink(
destination: TransactionEditView(
transaction,
shouldNavigateUp: self.$shouldNavigateUp
).navigationBarTitle("edit")
) {
Text("edit")
})
} else {
EmbeddedLoadingView().onAppear {
self.presentationMode.wrappedValue.dismiss()
}
} }
.navigationBarItems(trailing: NavigationLink(
destination: TransactionEditView(
transaction,
shouldNavigateUp: self.$shouldNavigateUp
).navigationBarTitle("edit")
) {
Text("edit")
})
.onAppear {
if self.shouldNavigateUp {
self.presentationMode.wrappedValue.dismiss()
}
}
}
init(_ transaction: Transaction) {
self.transaction = transaction
} }
} }
@ -81,21 +79,23 @@ struct CategoryLineItem: View {
var body: some View { var body: some View {
stateContent.onAppear { stateContent.onAppear {
if let id = self.categoryId { if let id = self.categoryId {
categoryDataStore.getCategory(id) Task {
try await categoryDataStore.getCategory(id)
}
} }
} }
} }
var stateContent: AnyView { @ViewBuilder
switch categoryDataStore.category { var stateContent: some View {
case .success(let category): if let category = self.categoryDataStore.category {
return AnyView(LabeledField(label: "category", value: category.title, showDivider: true)) LabeledField(label: "category", value: category.title, showDivider: true)
default: } else {
return AnyView(LabeledField(label: "category", value: "", showDivider: true)) LabeledField(label: "category", value: "", showDivider: true)
} }
} }
@EnvironmentObject var categoryDataStore: CategoryDataStore @EnvironmentObject var categoryDataStore: CategoryListDataStore
let categoryId: String? let categoryId: String?
init(_ categoryId: String?) { init(_ categoryId: String?) {
self.categoryId = categoryId self.categoryId = categoryId
@ -104,38 +104,22 @@ struct CategoryLineItem: View {
struct BudgetLineItem: View { struct BudgetLineItem: View {
@EnvironmentObject var budgetDataStore: BudgetsDataStore @EnvironmentObject var budgetDataStore: BudgetsDataStore
var budgetName: String {
get {
if case let .success(budget) = budgetDataStore.budget {
return budget.name
} else {
return ""
}
}
}
var body: some View { var body: some View {
LabeledField(label: "budget", value: budgetName, showDivider: true) LabeledField(label: "budget", value: self.budgetDataStore.budget?.name, showDivider: true)
} }
} }
struct UserLineItem: View { struct UserLineItem: View {
var body: some View { var body: some View {
stateContent.onAppear { LabeledField(label: "registered_by", value: userDataStore.user?.username, showDivider: false).onAppear {
userDataStore.getUser(userId) Task {
try await userDataStore.getUser(userId)
}
} }
} }
var stateContent: AnyView {
switch userDataStore.user {
case .success(let user):
return AnyView(LabeledField(label: "registered_by", value: user.username, showDivider: false))
default:
return AnyView(LabeledField(label: "registered_by", value: "", showDivider: false))
}
}
@EnvironmentObject var userDataStore: UserDataStore @EnvironmentObject var userDataStore: UserDataStore
let userId: String let userId: String
init(_ userId: String) { init(_ userId: String) {
@ -146,7 +130,7 @@ struct UserLineItem: View {
#if DEBUG #if DEBUG
struct TransactionDetailsView_Previews: PreviewProvider { struct TransactionDetailsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
TransactionDetailsView(MockTransactionRepository.transaction) TransactionDetailsView()
} }
} }
#endif #endif

View file

@ -7,8 +7,10 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
struct TransactionEditView: View { struct TransactionEditView: View {
@State var loading: Bool = false
@Environment(\.presentationMode) var presentationMode @Environment(\.presentationMode) var presentationMode
@State var title: String @State var title: String
@State var description: String @State var description: String
@ -19,22 +21,16 @@ struct TransactionEditView: View {
@State var categoryId: String @State var categoryId: String
var createdBy: String { var createdBy: String {
get { get {
try! authDataStore.currentUser.get().id return authDataStore.currentUser!.id
} }
} }
let id: String? let id: String?
var shouldNavigateUp: Binding<Bool> var shouldNavigateUp: Binding<Bool>
var stateContent: AnyView { @ViewBuilder
switch transactionDataStore.transaction { var stateContent: some View {
case .success(_), .failure(.deleted): if let _ = self.transactionDataStore.transaction {
self.shouldNavigateUp.wrappedValue = true EditTransactionForm(
self.presentationMode.wrappedValue.dismiss()
return AnyView(EmptyView())
case .failure(.loading):
return AnyView(EmbeddedLoadingView())
default:
return AnyView(EditTransactionForm(
title: self.$title, title: self.$title,
description: self.$description, description: self.$description,
date: self.$date, date: self.$date,
@ -43,9 +39,16 @@ struct TransactionEditView: View {
budgetId: self.$budgetId, budgetId: self.$budgetId,
categoryId: self.$categoryId, categoryId: self.$categoryId,
deleteAction: { deleteAction: {
self.transactionDataStore.deleteTransaction(self.id!) Task {
self.loading = true
try await self.transactionDataStore.deleteTransaction(self.id!)
}
})
} else {
EmbeddedLoadingView().onAppear {
self.shouldNavigateUp.wrappedValue = true
self.presentationMode.wrappedValue.dismiss()
} }
))
} }
} }
@ -53,23 +56,25 @@ struct TransactionEditView: View {
stateContent stateContent
.navigationBarItems(trailing: Button("save") { .navigationBarItems(trailing: Button("save") {
let amount = Double(self.amount) ?? 0.0 let amount = Double(self.amount) ?? 0.0
self.transactionDataStore.saveTransaction(Transaction( Task {
id: self.id ?? "", try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction(
title: self.title, id: self.id ?? "",
description: self.description, title: self.title,
date: self.date, description: self.description,
amount: Int(amount * 100.0), date: self.date,
categoryId: self.categoryId, amount: Int(amount * 100.0),
expense: self.type == TransactionType.expense, categoryId: self.categoryId,
createdBy: self.createdBy, expense: self.type == TransactionType.expense,
budgetId: self.budgetId createdBy: self.createdBy,
)) budgetId: self.budgetId
))
}
}) })
} }
@EnvironmentObject var transactionDataStore: TransactionDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore
@EnvironmentObject var authDataStore: AuthenticationDataStore @EnvironmentObject var authDataStore: AuthenticationDataStore
init(_ transaction: Transaction, shouldNavigateUp: Binding<Bool>) { init(_ transaction: TwigsCore.Transaction, shouldNavigateUp: Binding<Bool>) {
self.id = transaction.id self.id = transaction.id
self._title = State<String>(initialValue: transaction.title) self._title = State<String>(initialValue: transaction.title)
self._description = State<String>(initialValue: transaction.description ?? "") self._description = State<String>(initialValue: transaction.description ?? "")

View file

@ -9,22 +9,24 @@
import SwiftUI import SwiftUI
import Combine import Combine
import Collections import Collections
import TwigsCore
struct TransactionListView: View { struct TransactionListView<Content>: View where Content: View {
@EnvironmentObject var transactionDataStore: TransactionDataStore @EnvironmentObject var transactionDataStore: TransactionDataStore
@State var requestId: String = "" @State var requestId: String = ""
@State var isAddingTransaction = false @State var isAddingTransaction = false
@State var search: String = "" @State var search: String = ""
let header: AnyView? @ViewBuilder
let header: (() -> Content)?
@ViewBuilder @ViewBuilder
private func TransactionList(_ transactions: OrderedDictionary<String, [Transaction]>) -> some View { private func TransactionList(_ transactions: OrderedDictionary<String, [TwigsCore.Transaction]>) -> some View {
if transactions.isEmpty { if transactions.isEmpty {
Text("no_transactions") Text("no_transactions")
} else { } else {
if let header = header { if let header = header {
Section { Section {
header header()
} }
} }
ForEach(transactions.keys, id: \.self) { (key: String) in ForEach(transactions.keys, id: \.self) { (key: String) in
@ -47,8 +49,11 @@ struct TransactionListView: View {
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch transactionDataStore.transactions[requestId] { InlineLoadingView(
case .success(let transactions): action: { try await transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id) },
errorTextLocalizedStringKey: "Failed to load transactions"
) {
if let transactions = self.transactionDataStore.transactions {
List { List {
TransactionList(transactions) TransactionList(transactions)
} }
@ -67,26 +72,13 @@ struct TransactionListView: View {
} }
} }
) )
case nil, .failure(.loading):
ActivityIndicator(isAnimating: .constant(true), style: .large).onAppear {
if transactionDataStore.transactions[requestId] == nil || self.requestId == "" {
self.requestId = transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id)
}
}
default:
// TODO: Handle each network failure type
List {
Text("budgets_load_failure")
Button("action_retry", action: {
self.requestId = transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id)
})
}
} }
}
} }
let budget: Budget let budget: Budget
let category: Category? let category: TwigsCore.Category?
init(_ budget: Budget, category: Category? = nil, header: AnyView? = nil) { init(_ budget: Budget, category: TwigsCore.Category? = nil, header: (() -> Content)? = nil) {
self.budget = budget self.budget = budget
self.category = category self.category = category
self.header = header self.header = header
@ -94,38 +86,44 @@ struct TransactionListView: View {
} }
struct TransactionListItemView: View { struct TransactionListItemView: View {
var transaction: Transaction @EnvironmentObject var dataStore: TransactionDataStore
var transaction: TwigsCore.Transaction
var body: some View { var body: some View {
NavigationLink( NavigationLink(
destination: TransactionDetailsView(transaction) tag: self.transaction,
.navigationBarTitle("details", displayMode: .inline) selection: self.$dataStore.transaction,
) { destination: {
HStack { TransactionDetailsView().navigationBarTitle("details", displayMode: .inline)
VStack(alignment: .leading) { },
Text(verbatim: transaction.title) label: {
.lineLimit(1) HStack {
.font(.headline) VStack(alignment: .leading) {
if let description = transaction.description?.trimmingCharacters(in: CharacterSet([" "])), !description.isEmpty { Text(verbatim: transaction.title)
Text(verbatim: description)
.lineLimit(1) .lineLimit(1)
.font(.subheadline) .font(.headline)
.foregroundColor(.secondary) if let description = transaction.description?.trimmingCharacters(in: CharacterSet([" "])), !description.isEmpty {
Text(verbatim: description)
.lineLimit(1)
.font(.subheadline)
.foregroundColor(.secondary)
.multilineTextAlignment(.trailing)
}
}
Spacer()
VStack(alignment: .trailing) {
Text(verbatim: transaction.amount.toCurrencyString())
.foregroundColor(transaction.expense ? .red : .green)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
} }
} .padding(.leading)
Spacer() }.padding(5.0)
VStack(alignment: .trailing) {
Text(verbatim: transaction.amount.toCurrencyString()) }
.foregroundColor(transaction.expense ? .red : .green) )
.multilineTextAlignment(.trailing)
}
.padding(.leading)
}.padding(5.0)
}
} }
init (_ transaction: Transaction) { init (_ transaction: TwigsCore.Transaction) {
self.transaction = transaction self.transaction = transaction
} }
} }

View file

@ -8,18 +8,10 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
protocol TransactionRepository {
func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) -> AnyPublisher<[Transaction], NetworkError>
func getTransaction(_ transactionId: String) -> AnyPublisher<Transaction, NetworkError>
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError>
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError>
}
#if DEBUG #if DEBUG
class MockTransactionRepository: TransactionRepository { class MockTransactionRepository: TransactionRepository {
static let transaction: Transaction = Transaction( static let transaction: Transaction = Transaction(
id: "2", id: "2",
title: "Test Transaction", title: "Test Transaction",
@ -32,28 +24,28 @@ class MockTransactionRepository: TransactionRepository {
budgetId: MockBudgetRepository.budget.id budgetId: MockBudgetRepository.budget.id
) )
func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) -> AnyPublisher<[Transaction], NetworkError> { func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) async throws -> [Transaction] {
return Result.Publisher([MockTransactionRepository.transaction]).eraseToAnyPublisher() return [MockTransactionRepository.transaction]
} }
func getTransaction(_ transactionId: String) -> AnyPublisher<Transaction, NetworkError> { func getTransaction(_ transactionId: String) async throws -> Transaction {
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() return MockTransactionRepository.transaction
} }
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> { func createTransaction(_ transaction: Transaction) async throws -> Transaction {
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() return MockTransactionRepository.transaction
} }
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> { func updateTransaction(_ transaction: Transaction) async throws -> Transaction {
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() return MockTransactionRepository.transaction
} }
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> { func deleteTransaction(_ transactionId: String) async throws {
return Result.Publisher(.success(Empty())).eraseToAnyPublisher() // Do nothing
} }
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError> { func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) async throws -> BalanceResponse {
return Result.Publisher(.success(BalanceResponse(balance: 1000))).eraseToAnyPublisher() return BalanceResponse(balance: 1000)
} }
} }
#endif #endif

View file

@ -7,31 +7,24 @@
// //
import SwiftUI import SwiftUI
import TwigsCore
@main @main
struct TwigsApp: App { struct TwigsApp: App {
@StateObject var authDataStore: AuthenticationDataStore @AppStorage("BASE_URL") var baseUrl: String = ""
let apiService: TwigsApiService = TwigsInMemoryCacheService() @AppStorage("TOKEN") var token: String = ""
@AppStorage("USER_ID") var userId: String = ""
init() { let apiService: TwigsInMemoryCacheService = TwigsInMemoryCacheService()
let authDataStore = AuthenticationDataStore(self.apiService)
self._authDataStore = StateObject(wrappedValue: authDataStore)
}
@ViewBuilder
var mainView: some View {
if UIDevice.current.userInterfaceIdiom == .mac || UIDevice.current.userInterfaceIdiom == .pad {
SidebarBudgetView(apiService)
.environmentObject(authDataStore)
} else {
TabbedBudgetView(apiService)
.environmentObject(authDataStore)
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
mainView MainView(self.apiService, baseUrl: self.$baseUrl, token: self.$token, userId: self.$userId).onAppear {
print("TwigsApp.onAppear")
if self.baseUrl != "", self.token != "" {
self.apiService.baseUrl = self.baseUrl
self.apiService.token = self.token
}
}
} }
} }
} }

View file

@ -1,111 +1,100 @@
import Foundation import Foundation
import Combine import Combine
import SwiftUI import SwiftUI
import TwigsCore
class AuthenticationDataStore: ObservableObject { class AuthenticationDataStore: ObservableObject {
private var currentRequest: AnyCancellable? = nil @Published var loading: Bool = false {
@Published var currentUser: Result<User, UserStatus> = .failure(.unauthenticated) didSet {
var showLogin: Bool { print("authDataStore loading: \(self.loading)")
get {
switch currentUser {
case .success(_):
print("Authenticated")
return false
default:
print("Unauthenticated")
return true
}
} }
set { }
} }
@Published var currentUser: User? = nil {
func login(server: String, username: String, password: String) { didSet {
// Changes the status and notifies any observers of the change self.showLogin = self.currentUser == nil
self.currentUser = .failure(.authenticating)
// Perform the login
self.userRepository.setServer(server)
currentRequest = self.userRepository.login(username: username, password: password)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (status) in
switch status {
case .finished:
return
case .failure(let error):
self.currentRequest = nil
switch error {
case .jsonParsingFailed(let jsonError):
print(jsonError.localizedDescription)
default:
print(error.localizedDescription)
}
self.currentUser = .failure(.failedAuthentication)
}
}) { (session) in
UserDefaults.standard.set(session.token, forKey: TOKEN)
UserDefaults.standard.set(session.userId, forKey: USER_ID)
self.loadProfile()
} }
} }
@Binding private var baseUrl: String
@Binding private var token: String
@Binding private var userId: String
@Published var showLogin: Bool = true
let apiService: TwigsApiService
init(_ apiService: TwigsApiService, baseUrl: Binding<String>, token: Binding<String>, userId: Binding<String>) {
self.apiService = apiService
self._baseUrl = baseUrl
self._token = token
self._userId = userId
}
func register(username: String, email: String, password: String, confirmPassword: String) { func login(server: String, username: String, password: String) async throws {
self.currentUser = .failure(.authenticating) self.loading = true
defer {
self.loading = false
}
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)
} catch {
switch error {
case NetworkError.jsonParsingFailed(let jsonError):
print(jsonError.localizedDescription)
default:
print(error.localizedDescription)
}
return
}
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
}
// TODO: Validate other fields as well // TODO: Validate other fields as well
if !password.elementsEqual(confirmPassword) { if !password.elementsEqual(confirmPassword) {
self.currentUser = .failure(.passwordMismatch) // TODO: Show error message to user
return return
} }
currentRequest = self.userRepository.register(username: username, email: email, password: password) self.apiService.baseUrl = server
.receive(on: DispatchQueue.main) // The API Service applies some validation and correcting of the server before returning it so we use that
.sink(receiveCompletion: { (status) in // value instead of the original one
switch status { self.baseUrl = self.apiService.baseUrl ?? ""
case .finished: do {
return _ = try await apiService.register(username: username, email: email, password: password)
case .failure( _): } catch {
self.currentUser = .failure(.failedAuthentication) switch error {
} case NetworkError.jsonParsingFailed(let jsonError):
}) { (user) in print(jsonError.localizedDescription)
self.currentUser = .success(user) default:
} print(error.localizedDescription)
} }
private func loadProfile() {
guard let userId = UserDefaults.standard.string(forKey: USER_ID) else {
self.currentUser = .failure(.unauthenticated)
return return
} }
guard let token = UserDefaults.standard.string(forKey: TOKEN) else { try await self.login(server: server, username: username, password: password)
self.currentUser = .failure(.unauthenticated)
return
}
self.currentUser = .failure(.authenticating)
self.userRepository.setToken(token)
currentRequest = self.userRepository.getUser(userId)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (status) in
switch status {
case .finished:
self.currentRequest = nil
return
case .failure(_):
self.currentUser = .failure(.unauthenticated)
}
}) { (user) in
self.currentUser = .success(user)
}
} }
init(_ userRepository: UserRepository) { func loadProfile() async throws {
self.userRepository = userRepository self.loading = true
if UserDefaults.standard.string(forKey: TOKEN) != nil { defer {
loadProfile() self.loading = false
} }
if userId == "" {
throw UserStatus.unauthenticated
}
self.currentUser = try await self.apiService.getUser(userId)
} }
private let userRepository: UserRepository
} }
private let BASE_URL = "BASE_URL"
private let TOKEN = "TOKEN" private let TOKEN = "TOKEN"
private let USER_ID = "USER_ID" private let USER_ID = "USER_ID"
@ -114,14 +103,5 @@ enum UserStatus: Error, Equatable {
case authenticating case authenticating
case failedAuthentication case failedAuthentication
case authenticated case authenticated
case passwordMismatch // Passwords don't match case passwordMismatch
} }
#if DEBUG
class MockAuthenticationDataStore: AuthenticationDataStore {
override init(_ userRepository: UserRepository) {
super.init(userRepository)
self.currentUser = .success(User(id: "1", username: "test_user", email: "test@localhost.loc", avatar: nil))
}
}
#endif

View file

@ -1,33 +0,0 @@
//
// User.swift
// Budget
//
// Created by Billy Brawner on 9/25/19.
// Copyright © 2019 William Brawner. All rights reserved.
//
import Foundation
struct User: Codable, Equatable, Hashable {
let id: String
let username: String
let email: String?
let avatar: String?
}
struct LoginRequest: Codable {
let username: String
let password: String
}
struct LoginResponse: Codable {
let token: String
let expiration: String
let userId: String
}
struct RegistrationRequest: Codable {
let username: String
let email: String
let password: String
}

View file

@ -8,29 +8,18 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
class UserDataStore: ObservableObject { class UserDataStore: AsyncObservableObject {
private var currentRequest: AnyCancellable? = nil @Published var user: AsyncData<User> = .empty
@Published var user: Result<User, NetworkError> = .failure(.loading)
func getUser(_ id: String) { func getUser(_ id: String) async {
self.user = .failure(.loading) do {
let user = try await self.userRepository.getUser(id)
self.currentRequest = userRepository.getUser(id)
.receive(on: DispatchQueue.main)
.sink(receiveCompletion: { (status) in
switch status {
case .finished:
self.currentRequest = nil
return
case .failure(let error):
self.user = .failure(error)
return
}
}, receiveValue: { (user) in
self.user = .success(user) self.user = .success(user)
}) } catch {
self.user = .error(error)
}
} }
private let userRepository: UserRepository private let userRepository: UserRepository

View file

@ -8,18 +8,9 @@
import Foundation import Foundation
import Combine import Combine
import TwigsCore
protocol UserRepository {
func setToken(_ token: String)
func getUser(_ id: String) -> AnyPublisher<User, NetworkError>
func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError>
func setServer(_ server: String)
func login(username: String, password: String) -> AnyPublisher<LoginResponse, NetworkError>
func register(username: String, email: String, password: String) -> AnyPublisher<User, NetworkError>
}
#if DEBUG #if DEBUG
class MockUserRepository: UserRepository { class MockUserRepository: UserRepository {
static let loginResponse = LoginResponse(token: "token", expiration: "2020-01-01T12:00:00Z", userId: "0") static let loginResponse = LoginResponse(token: "token", expiration: "2020-01-01T12:00:00Z", userId: "0")
static let user = User(id: "0", username: "root", email: "root@localhost", avatar: nil) static let user = User(id: "0", username: "root", email: "root@localhost", avatar: nil)
@ -29,27 +20,23 @@ class MockUserRepository: UserRepository {
MockUserRepository.token = token MockUserRepository.token = token
} }
func getUser(_ id: String) -> AnyPublisher<User, NetworkError> { func getUser(_ id: String) async throws -> User {
return Result<User, NetworkError>.Publisher(MockUserRepository.user) return MockUserRepository.user
.eraseToAnyPublisher()
} }
func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> { func searchUsers(_ withUsername: String) async throws -> [User] {
return Result<[User], NetworkError>.Publisher([MockUserRepository.user]) return [MockUserRepository.user]
.eraseToAnyPublisher()
} }
func setServer(_ server: String) { func setServer(_ server: String) {
} }
func login(username: String, password: String) -> AnyPublisher<LoginResponse, NetworkError> { func login(username: String, password: String) async throws -> LoginResponse {
return Result<LoginResponse, NetworkError>.Publisher(MockUserRepository.loginResponse) return MockUserRepository.loginResponse
.eraseToAnyPublisher()
} }
func register(username: String, email: String, password: String) -> AnyPublisher<User, NetworkError> { func register(username: String, email: String, password: String) async throws -> User {
return Result<User, NetworkError>.Publisher(MockUserRepository.user) return MockUserRepository.user
.eraseToAnyPublisher()
} }
} }

View file

@ -0,0 +1,46 @@
//
// InlineLoadingView.swift
// Twigs
//
// Created by William Brawner on 12/28/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import SwiftUI
struct InlineLoadingView<Content, Data>: View where Content: View, Data: Equatable {
@Binding var data: AsyncData<Data>
let action: () async -> Void
let errorTextLocalizedStringKey: String
@ViewBuilder
let successBody: (Data) -> Content
@ViewBuilder
var body: some View {
switch self.data {
case .empty, .loading:
ActivityIndicator(isAnimating: .constant(true), style: .large)
.task {
await action()
}
case .error(let error):
Text(LocalizedStringKey(errorTextLocalizedStringKey))
Text(error.localizedDescription)
Button(LocalizedStringKey("action_retry"), action: {
Task {
await action()
}
})
case .success(let data):
successBody(data)
}
}
}
#if DEBUG
struct InlineLoadingView_Previews: PreviewProvider {
static var previews: some View {
InlineLoadingView(action: {}, errorTextLocalizedStringKey: "An error ocurred", successBody: { EmptyView() })
}
}
#endif

View file

@ -0,0 +1,50 @@
//
// MainView.swift
// Twigs
//
// Created by William Brawner on 12/30/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import SwiftUI
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)
} else {
TabbedBudgetView(apiService: apiService)
.environmentObject(authenticationDataStore)
.environmentObject(budgetDataStore)
}
}
var body: some View {
mainView.onAppear {
print("MainView.onAppear")
Task {
try await self.authenticationDataStore.loadProfile()
}
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(TwigsInMemoryCacheService(), baseUrl: .constant(""), token: .constant(""), userId: .constant(""))
}
}