WIP: Convert codebase to async/await
This commit is contained in:
parent
bcab5fa078
commit
27c7a51b1f
44 changed files with 1122 additions and 2020 deletions
|
@ -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
33
Twigs/AsyncData.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
32
Twigs/AsyncObservableObject.swift
Normal file
32
Twigs/AsyncObservableObject.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
73
Twigs/Category/CategoryListDataStore.swift
Normal file
73
Twigs/Category/CategoryListDataStore.swift
Normal 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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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())
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 }
|
|
||||||
}
|
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,6 +7,7 @@
|
||||||
//
|
//
|
||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
import TwigsCore
|
||||||
|
|
||||||
struct RecurringTransactionDetailsView: View {
|
struct RecurringTransactionDetailsView: View {
|
||||||
let transaction: RecurringTransaction
|
let transaction: RecurringTransaction
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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: {
|
||||||
|
|
|
@ -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 = ""
|
||||||
|
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 ?? "")
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
46
Twigs/Views/InlineLoadingView.swift
Normal file
46
Twigs/Views/InlineLoadingView.swift
Normal 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
|
50
Twigs/Views/MainView.swift
Normal file
50
Twigs/Views/MainView.swift
Normal 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(""))
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue