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 */; };
|
||||
282126BD235CDE1400072D52 /* ProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BC235CDE1400072D52 /* ProgressView.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 */; };
|
||||
284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 284102312342E12F00EAFA29 /* CategoryListView.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 */; };
|
||||
28A1E95A235006A300CA57FE /* AddTransactionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A1E959235006A300CA57FE /* AddTransactionView.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 */; };
|
||||
28AC9510233C373A00BFB70A /* BudgetUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC950F233C373A00BFB70A /* BudgetUITests.swift */; };
|
||||
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 */; };
|
||||
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 */; };
|
||||
28CE8B9523525F990072BC4C /* Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28CE8B9423525F990072BC4C /* Extensions.swift */; };
|
||||
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */; };
|
||||
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */; };
|
||||
28FE6AF823441E1D00D5543E /* Category.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF723441E1D00D5543E /* Category.swift */; };
|
||||
28FE6AFA23441E3700D5543E /* CategoryDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF923441E3700D5543E /* CategoryDataStore.swift */; };
|
||||
28FE6AFA23441E3700D5543E /* CategoryListDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */; };
|
||||
28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */; };
|
||||
28FE6B002344308600D5543E /* Transaction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6AFF2344308600D5543E /* Transaction.swift */; };
|
||||
28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B012344331B00D5543E /* TransactionDataStore.swift */; };
|
||||
28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B03234449DC00D5543E /* TransactionListView.swift */; };
|
||||
28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.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 */; };
|
||||
801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */; };
|
||||
801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.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 */; };
|
||||
8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */; };
|
||||
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 806C784F272B700B00FA1375 /* TwigsApp.swift */; };
|
||||
80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 80820144275FFD380040996E /* SidebarBudgetView.swift */; };
|
||||
8094A9C327567CAC006C6C62 /* Collections in Frameworks */ = {isa = PBXBuildFile; productRef = 8094A9C227567CAC006C6C62 /* Collections */; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
|
@ -82,11 +82,9 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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; };
|
||||
|
@ -102,32 +100,35 @@
|
|||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = "<group>"; };
|
||||
28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListDataStore.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>"; };
|
||||
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>"; };
|
||||
28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = "<group>"; };
|
||||
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = "<group>"; };
|
||||
8005FD54277E61DC00E48B23 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
|
||||
8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
|
||||
800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = "<group>"; };
|
||||
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
|
@ -135,6 +136,7 @@
|
|||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
8005FD5B277E623900E48B23 /* TwigsCore in Frameworks */,
|
||||
8094A9C327567CAC006C6C62 /* Collections in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
|
@ -168,7 +170,6 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
2841022F2342D97300EAFA29 /* BudgetListsView.swift */,
|
||||
2841022B2342D8E400EAFA29 /* Budget.swift */,
|
||||
28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */,
|
||||
28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */,
|
||||
282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */,
|
||||
|
@ -179,12 +180,12 @@
|
|||
2841022A2342D8CB00EAFA29 /* Category */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
28FE6AF723441E1D00D5543E /* Category.swift */,
|
||||
28FE6AF923441E3700D5543E /* CategoryDataStore.swift */,
|
||||
28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */,
|
||||
8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */,
|
||||
284102312342E12F00EAFA29 /* CategoryListView.swift */,
|
||||
28FE6AFB23441E4500D5543E /* CategoryRepository.swift */,
|
||||
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */,
|
||||
8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */,
|
||||
);
|
||||
path = Category;
|
||||
sourceTree = "<group>";
|
||||
|
@ -194,6 +195,8 @@
|
|||
children = (
|
||||
2857EAEC233DA30B0026BC83 /* LoadingView.swift */,
|
||||
282126BC235CDE1400072D52 /* ProgressView.swift */,
|
||||
80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */,
|
||||
8005FD5C277EAB0200E48B23 /* MainView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
|
@ -201,10 +204,13 @@
|
|||
28AC94E1233C373900BFB70A = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
80DBED432774AE4F00CB0A88 /* Packages */,
|
||||
8005FD53277E61DC00E48B23 /* twigs-cli */,
|
||||
28AC94EB233C373900BFB70A /* Products */,
|
||||
28AC94EC233C373900BFB70A /* Twigs */,
|
||||
28AC9503233C373A00BFB70A /* TwigsTests */,
|
||||
28AC950E233C373A00BFB70A /* TwigsUITests */,
|
||||
8005FD59277E623900E48B23 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
|
@ -225,7 +231,6 @@
|
|||
28AC94FB233C373A00BFB70A /* Info.plist */,
|
||||
28CE8B9423525F990072BC4C /* Extensions.swift */,
|
||||
28AC94F1233C373900BFB70A /* LoginView.swift */,
|
||||
2888234623512DBF003D3847 /* Observable.swift */,
|
||||
28B9E50D2346BCB2007C3909 /* RegistrationView.swift */,
|
||||
80820144275FFD380040996E /* SidebarBudgetView.swift */,
|
||||
2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */,
|
||||
|
@ -242,6 +247,8 @@
|
|||
28AC9526233C42F800BFB70A /* Transaction */,
|
||||
28AC952A233C433C00BFB70A /* User */,
|
||||
2857EAEB233DA2F90026BC83 /* Views */,
|
||||
802161CF277647920075761A /* AsyncObservableObject.swift */,
|
||||
800DFC2B277FF47A00EDCE9B /* AsyncData.swift */,
|
||||
);
|
||||
path = Twigs;
|
||||
sourceTree = "<group>";
|
||||
|
@ -290,7 +297,6 @@
|
|||
28AC9527233C430A00BFB70A /* Network */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
28AC9524233C42D100BFB70A /* TwigsApiService.swift */,
|
||||
282126A2235ABC1800072D52 /* TwigsInMemoryCacheService.swift */,
|
||||
);
|
||||
path = Network;
|
||||
|
@ -300,17 +306,30 @@
|
|||
isa = PBXGroup;
|
||||
children = (
|
||||
28AC952B233C434800BFB70A /* UserRepository.swift */,
|
||||
28AC952D233C43A300BFB70A /* User.swift */,
|
||||
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */,
|
||||
289510232352AAFC00BC862B /* UserDataStore.swift */,
|
||||
);
|
||||
path = User;
|
||||
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 */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */,
|
||||
801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */,
|
||||
801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */,
|
||||
801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */,
|
||||
|
@ -319,6 +338,14 @@
|
|||
path = "Recurring Transactions";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
80DBED432774AE4F00CB0A88 /* Packages */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
808582CD277E5E9E00006859 /* TwigsCore */,
|
||||
);
|
||||
name = Packages;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
|
@ -337,6 +364,7 @@
|
|||
name = Twigs;
|
||||
packageProductDependencies = (
|
||||
8094A9C227567CAC006C6C62 /* Collections */,
|
||||
8005FD5A277E623900E48B23 /* TwigsCore */,
|
||||
);
|
||||
productName = Budget;
|
||||
productReference = 28AC94EA233C373900BFB70A /* Twigs.app */;
|
||||
|
@ -384,7 +412,7 @@
|
|||
28AC94E2233C373900BFB70A /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
LastSwiftUpdateCheck = 1100;
|
||||
LastSwiftUpdateCheck = 1320;
|
||||
LastUpgradeCheck = 1250;
|
||||
ORGANIZATIONNAME = "William Brawner";
|
||||
TargetAttributes = {
|
||||
|
@ -462,40 +490,39 @@
|
|||
2821266023555FD300072D52 /* EditTransactionForm.swift in Sources */,
|
||||
282126622357E45F00072D52 /* TransactionEditView.swift in Sources */,
|
||||
28FE6AFC23441E4500D5543E /* CategoryRepository.swift in Sources */,
|
||||
2841022C2342D8E400EAFA29 /* Budget.swift in Sources */,
|
||||
801D08CE275F189E00931465 /* RecurringTransactionsRepository.swift in Sources */,
|
||||
2841022723419A2B00EAFA29 /* TabbedBudgetView.swift in Sources */,
|
||||
801D08D2275FB7DE00931465 /* RecurringTransactionDetailsView.swift in Sources */,
|
||||
28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */,
|
||||
80D1FC14277C1EF9007F17FB /* InlineLoadingView.swift in Sources */,
|
||||
28FE6AF42342E3CB00D5543E /* BudgetsDataStore.swift in Sources */,
|
||||
28AC952C233C434800BFB70A /* UserRepository.swift in Sources */,
|
||||
28FE6B04234449DC00D5543E /* TransactionListView.swift in Sources */,
|
||||
28AC94F2233C373900BFB70A /* LoginView.swift in Sources */,
|
||||
802161D0277647920075761A /* AsyncObservableObject.swift in Sources */,
|
||||
282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */,
|
||||
801D08D0275F1AE300931465 /* RecurringTransactionDataStore.swift in Sources */,
|
||||
28AC9525233C42D100BFB70A /* TwigsApiService.swift in Sources */,
|
||||
2888234723512DBF003D3847 /* Observable.swift in Sources */,
|
||||
2857EAED233DA30B0026BC83 /* LoadingView.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 */,
|
||||
284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */,
|
||||
8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */,
|
||||
289510242352AAFC00BC862B /* UserDataStore.swift in Sources */,
|
||||
28FE6B002344308600D5543E /* Transaction.swift in Sources */,
|
||||
28FE6AF823441E1D00D5543E /* Category.swift in Sources */,
|
||||
8005FD5D277EAB0200E48B23 /* MainView.swift in Sources */,
|
||||
282126A1235929B800072D52 /* ProfileView.swift in Sources */,
|
||||
28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */,
|
||||
809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */,
|
||||
28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */,
|
||||
80D6B1F1275B11DE0075D0EC /* RecurringTransaction.swift in Sources */,
|
||||
28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */,
|
||||
543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */,
|
||||
80820145275FFD380040996E /* SidebarBudgetView.swift in Sources */,
|
||||
8044BA3927828E9D009A78D4 /* CategoryDataStore.swift in Sources */,
|
||||
284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */,
|
||||
282126BD235CDE1400072D52 /* ProgressView.swift in Sources */,
|
||||
806C7850272B700B00FA1375 /* TwigsApp.swift in Sources */,
|
||||
28AC952E233C43A300BFB70A /* User.swift in Sources */,
|
||||
28CE8B9523525F990072BC4C /* Extensions.swift in Sources */,
|
||||
801D08CC275ECEFA00931465 /* RecurringTransactionsListView.swift in Sources */,
|
||||
);
|
||||
|
@ -866,6 +893,10 @@
|
|||
/* End XCRemoteSwiftPackageReference section */
|
||||
|
||||
/* Begin XCSwiftPackageProductDependency section */
|
||||
8005FD5A277E623900E48B23 /* TwigsCore */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
productName = TwigsCore;
|
||||
};
|
||||
8094A9C227567CAC006C6C62 /* Collections */ = {
|
||||
isa = XCSwiftPackageProductDependency;
|
||||
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 TwigsCore
|
||||
|
||||
struct BudgetDetailsView: View {
|
||||
@EnvironmentObject var budgetDataStore: BudgetsDataStore
|
||||
|
@ -14,56 +15,81 @@ struct BudgetDetailsView: View {
|
|||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch budgetDataStore.overview {
|
||||
case .failure(.loading):
|
||||
ActivityIndicator(isAnimating: .constant(true), style: .large)
|
||||
case .success(let overview):
|
||||
List {
|
||||
Section(overview.budget.name) {
|
||||
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)
|
||||
}
|
||||
InlineLoadingView(
|
||||
data: self.$budgetDataStore.overview,
|
||||
action: { await self.budgetDataStore.loadOverview(self.budget) },
|
||||
errorTextLocalizedStringKey: "budgets_load_failure"
|
||||
) {
|
||||
if let overview = self.budgetDataStore.overview {
|
||||
List {
|
||||
Section(overview.budget.name) {
|
||||
DescriptionOverview(overview: overview)
|
||||
}
|
||||
}
|
||||
Section("income") {
|
||||
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("income") {
|
||||
IncomeOverview(overview: overview)
|
||||
}
|
||||
}
|
||||
Section("expenses") {
|
||||
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)
|
||||
Section("expenses") {
|
||||
ExpensesOverview(overview: overview)
|
||||
}
|
||||
}
|
||||
}.listStyle(.insetGrouped)
|
||||
default:
|
||||
Text("An error has ocurred")
|
||||
}.listStyle(.insetGrouped)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 Combine
|
||||
import TwigsCore
|
||||
|
||||
struct BudgetListsView: View {
|
||||
@EnvironmentObject var budgetDataStore: BudgetsDataStore
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch budgetDataStore.budgets {
|
||||
case .success(let budgets):
|
||||
InlineLoadingView(
|
||||
action: { return try await self.budgetDataStore.getBudgets(count: nil, page: nil) },
|
||||
errorTextLocalizedStringKey: "budgets_load_failure"
|
||||
) { (budgets: [Budget]) in
|
||||
Section("budgets") {
|
||||
ForEach(budgets) { budget in
|
||||
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 Combine
|
||||
|
||||
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>
|
||||
}
|
||||
import TwigsCore
|
||||
|
||||
#if DEBUG
|
||||
class MockBudgetRepository: BudgetRepository {
|
||||
|
@ -26,29 +19,28 @@ class MockBudgetRepository: BudgetRepository {
|
|||
currencyCode: "USD"
|
||||
)
|
||||
|
||||
func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError> {
|
||||
return Result.Publisher([MockBudgetRepository.budget]).eraseToAnyPublisher()
|
||||
func getBudgets(count: Int?, page: Int?) async throws -> [Budget] {
|
||||
return [MockBudgetRepository.budget]
|
||||
}
|
||||
|
||||
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> {
|
||||
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher()
|
||||
func getBudget(_ id: String) async throws -> Budget {
|
||||
return MockBudgetRepository.budget
|
||||
}
|
||||
|
||||
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
|
||||
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher()
|
||||
func newBudget(_ budget: Budget) async throws -> Budget {
|
||||
return MockBudgetRepository.budget
|
||||
}
|
||||
|
||||
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
|
||||
return Result.Publisher(Budget(
|
||||
func updateBudget(_ budget: Budget) async throws -> Budget {
|
||||
return Budget(
|
||||
id: "1",
|
||||
name: "Test Budget",
|
||||
description: "A mock budget used for testing",
|
||||
currencyCode: "USD"
|
||||
)).eraseToAnyPublisher()
|
||||
)
|
||||
}
|
||||
|
||||
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return Result.Publisher(Empty()).eraseToAnyPublisher()
|
||||
func deleteBudget(_ id: String) async throws {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -8,177 +8,92 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
import TwigsCore
|
||||
|
||||
private let LAST_BUDGET = "LAST_BUDGET"
|
||||
|
||||
@MainActor
|
||||
class BudgetsDataStore: ObservableObject {
|
||||
private let budgetRepository: BudgetRepository
|
||||
private let categoryRepository: CategoryRepository
|
||||
private let transactionRepository: TransactionRepository
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
@Published var budgets: Result<[Budget], NetworkError> = .failure(.loading)
|
||||
@Published var budget: Result<Budget, NetworkError>? = .failure(.loading) {
|
||||
@Published var budgets: AsyncData<[Budget]> = .empty
|
||||
@Published var budget: AsyncData<Budget> = .empty {
|
||||
didSet {
|
||||
self.overview = .empty
|
||||
if case let .success(budget) = self.budget {
|
||||
UserDefaults.standard.set(budget.id, forKey: LAST_BUDGET)
|
||||
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
|
||||
|
||||
init(budgetRepository: BudgetRepository, categoryRepository: CategoryRepository, transactionRepository: TransactionRepository) {
|
||||
self.budgetRepository = budgetRepository
|
||||
self.categoryRepository = categoryRepository
|
||||
self.transactionRepository = transactionRepository
|
||||
self.getBudgets(count: nil, page: nil)
|
||||
}
|
||||
|
||||
func getBudgets(count: Int? = nil, page: Int? = nil) {
|
||||
self.budgets = .failure(.loading)
|
||||
|
||||
self.currentRequest = self.budgetRepository.getBudgets(count: count, page: page)
|
||||
.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 budgets: \(networkError.name)")
|
||||
}
|
||||
default:
|
||||
print("failed to load budgets: \(error.name)")
|
||||
}
|
||||
|
||||
self.budgets = .failure(error)
|
||||
return
|
||||
func getBudgets(count: Int? = nil, page: Int? = nil) async {
|
||||
// TODO: Find some way to extract this to a generic function
|
||||
self.budgets = .loading
|
||||
do {
|
||||
let budgets = try await self.budgetRepository.getBudgets(count: count, page: page).sorted(by: { $0.name < $1.name })
|
||||
self.budgets = .success(budgets)
|
||||
if self.budget != .empty {
|
||||
return
|
||||
}
|
||||
if let id = UserDefaults.standard.string(forKey: LAST_BUDGET), let lastBudget = budgets.first(where: { $0.id == id }) {
|
||||
self.budget = .success(lastBudget)
|
||||
} else {
|
||||
if let budget = budgets.first {
|
||||
self.budget = .success(budget)
|
||||
}
|
||||
}, receiveValue: { (budgets) in
|
||||
self.budgets = .success(budgets.sorted(by: { $0.name < $1.name }))
|
||||
if case .success(_) = self.budget {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
self.budgets = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func loadOverview(_ budget: Budget) {
|
||||
self.overview = .failure(.loading)
|
||||
self.currentRequest = self.transactionRepository.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { (status) in
|
||||
switch status {
|
||||
case .finished:
|
||||
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)")
|
||||
func loadOverview(_ budget: Budget) async {
|
||||
self.overview = .loading
|
||||
do {
|
||||
let budgetBalance = try await self.transactionRepository.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil)
|
||||
let categories = try await self.categoryRepository.getCategories(budgetId: budget.id, expense: nil, archived: false, count: nil, page: nil)
|
||||
var budgetOverview = BudgetOverview(budget: budget, balance: budgetBalance.balance)
|
||||
try await withThrowingTaskGroup(of: (TwigsCore.Category, BalanceResponse).self) { group in
|
||||
for category in categories {
|
||||
group.addTask {
|
||||
return (category, try await self.transactionRepository.sumTransactions(budgetId: nil, categoryId: category.id, from: nil, to: nil))
|
||||
}
|
||||
self.budgets = .failure(error)
|
||||
self.currentRequest = nil
|
||||
return
|
||||
}
|
||||
}, receiveValue: { (response) in
|
||||
self.sumCategories(budget: budget, balance: response.balance)
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
for try await (category, response) in group {
|
||||
if category.expense {
|
||||
budgetOverview.expectedExpenses += category.amount
|
||||
} else {
|
||||
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)
|
||||
}.eraseToAnyPublisher())
|
||||
}
|
||||
|
||||
self.currentRequest = Publishers.MergeMany(categorySums)
|
||||
.collect()
|
||||
.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.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)
|
||||
})
|
||||
})
|
||||
if category.expense {
|
||||
budgetOverview.actualExpenses += abs(response.balance)
|
||||
} else {
|
||||
budgetOverview.actualIncome += response.balance
|
||||
}
|
||||
}
|
||||
}
|
||||
self.overview = .success(budgetOverview)
|
||||
} catch {
|
||||
self.overview = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
func selectBudget(_ budget: 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
|
||||
// Budget
|
||||
// Twigs
|
||||
//
|
||||
// Created by Billy Brawner on 10/1/19.
|
||||
// Copyright © 2019 William Brawner. All rights reserved.
|
||||
// Created by William Brawner on 1/2/22.
|
||||
// Copyright © 2022 William Brawner. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Combine
|
||||
import TwigsCore
|
||||
|
||||
@MainActor
|
||||
class CategoryDataStore: ObservableObject {
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
@Published var categories: [String:Result<[Category], NetworkError>] = ["":.failure(.loading)]
|
||||
@Published var category: Result<Category, NetworkError> = .failure(.unknown)
|
||||
@Published var sum: AsyncData<Int> = .empty
|
||||
let transactionRepository: TransactionRepository
|
||||
|
||||
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = false, count: Int? = nil, page: Int? = nil) -> String {
|
||||
let requestId = "\(budgetId ?? "all")-\(String(describing: expense))-\(String(describing: archived))"
|
||||
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
|
||||
init(transactionRepository: TransactionRepository) {
|
||||
self.transactionRepository = transactionRepository
|
||||
}
|
||||
|
||||
func getCategory(_ categoryId: String) {
|
||||
self.category = .failure(.loading)
|
||||
|
||||
self.currentRequest = categoryRepository.getCategory(categoryId)
|
||||
.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 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)
|
||||
func sum(categoryId: String, from: Date? = nil, to: Date? = nil) async {
|
||||
self.sum = .loading
|
||||
do {
|
||||
let sum = try await self.transactionRepository.sumTransactions(budgetId: nil, categoryId: categoryId, from: from, to: to).balance
|
||||
self.sum = .success(sum)
|
||||
} catch {
|
||||
self.sum = .error(error)
|
||||
}
|
||||
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) {
|
||||
self.category = .failure(.loading)
|
||||
self.currentRequest = self.categoryRepository.deleteCategory(id)
|
||||
.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: { _ in
|
||||
self.category = .failure(.deleted)
|
||||
})
|
||||
}
|
||||
|
||||
func clearSelectedCategory() {
|
||||
self.category = .failure(.unknown)
|
||||
}
|
||||
|
||||
private let categoryRepository: CategoryRepository
|
||||
init(_ categoryRepository: CategoryRepository) {
|
||||
self.categoryRepository = categoryRepository
|
||||
func save(_ category: TwigsCore.Category) async {
|
||||
self.category = .loading
|
||||
do {
|
||||
var savedCategory: TwigsCore.Category
|
||||
if category.id != "" {
|
||||
savedCategory = try await self.categoryRepository.updateCategory(category)
|
||||
} else {
|
||||
savedCategory = try await self.categoryRepository.createCategory(category)
|
||||
}
|
||||
self.category = .success(savedCategory)
|
||||
} catch {
|
||||
self.category = .error(error, category)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,17 +7,18 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct CategoryDetailsView: View {
|
||||
@EnvironmentObject var transactionDataStore: TransactionDataStore
|
||||
let budget: Budget
|
||||
let category: Category
|
||||
@State var sumRequest: String = ""
|
||||
let category: TwigsCore.Category
|
||||
@State var sum: Int? = 0
|
||||
@State var editingCategory: Bool = false
|
||||
var spent: Int {
|
||||
get {
|
||||
if case let .success(res) = transactionDataStore.sums[sumRequest] {
|
||||
return abs(res.balance)
|
||||
if let sum = self.sum {
|
||||
return abs(sum)
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
|
@ -39,19 +40,21 @@ struct CategoryDetailsView: View {
|
|||
}
|
||||
|
||||
var body: some View {
|
||||
TransactionListView(self.budget, category: category, header: VStack {
|
||||
Text(verbatim: category.description ?? "")
|
||||
.padding()
|
||||
HStack {
|
||||
LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount)
|
||||
LabeledCounter(title: middleLabel, amount: spent)
|
||||
LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .center).eraseToAnyView())
|
||||
TransactionListView(self.budget, category: category) {
|
||||
VStack {
|
||||
Text(verbatim: category.description ?? "")
|
||||
.padding()
|
||||
HStack {
|
||||
LabeledCounter(title: LocalizedStringKey("amount_budgeted"), amount: category.amount)
|
||||
LabeledCounter(title: middleLabel, amount: spent)
|
||||
LabeledCounter(title: LocalizedStringKey("amount_remaining"), amount: remaining)
|
||||
}
|
||||
}.frame(maxWidth: .infinity, alignment: .center)
|
||||
}
|
||||
|
||||
.onAppear {
|
||||
if sumRequest == "" || !sumRequest.contains(category.id) {
|
||||
sumRequest = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil)
|
||||
Task {
|
||||
try await self.sum = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil)
|
||||
}
|
||||
}
|
||||
.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.budget = budget
|
||||
}
|
||||
|
|
|
@ -7,9 +7,11 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct CategoryFormSheet: View {
|
||||
@EnvironmentObject var categoryDataStore: CategoryDataStore
|
||||
@EnvironmentObject var categoryDataStore: CategoryListDataStore
|
||||
@State var loading: Bool = false
|
||||
@Binding var showSheet: Bool
|
||||
@State var title: String
|
||||
@State var description: String
|
||||
|
@ -20,15 +22,16 @@ struct CategoryFormSheet: View {
|
|||
let budgetId: String
|
||||
@State private var showingAlert = false
|
||||
|
||||
var stateContent: AnyView {
|
||||
switch categoryDataStore.category {
|
||||
case .success(_):
|
||||
self.showSheet = false
|
||||
return AnyView(EmptyView())
|
||||
case .failure(.loading):
|
||||
return AnyView(EmbeddedLoadingView())
|
||||
default:
|
||||
return AnyView(Form {
|
||||
@ViewBuilder
|
||||
var stateContent: some View {
|
||||
if let _ = self.categoryDataStore.category {
|
||||
EmbeddedLoadingView().onAppear {
|
||||
self.showSheet = false
|
||||
}
|
||||
} else if self.loading {
|
||||
EmbeddedLoadingView()
|
||||
} else {
|
||||
Form {
|
||||
TextField("prompt_name", text: self.$title)
|
||||
.textInputAutocapitalization(.words)
|
||||
TextField("prompt_description", text: self.$description)
|
||||
|
@ -50,13 +53,17 @@ struct CategoryFormSheet: View {
|
|||
}
|
||||
.alert(isPresented:$showingAlert) {
|
||||
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())
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -70,23 +77,28 @@ struct CategoryFormSheet: View {
|
|||
},
|
||||
trailing: Button("save") {
|
||||
let amount = Double(self.amount) ?? 0.0
|
||||
self.categoryDataStore.save(Category(
|
||||
budgetId: self.budgetId,
|
||||
id: self.categoryId,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
amount: Int(amount * 100.0),
|
||||
expense: self.type == TransactionType.expense,
|
||||
archived: false
|
||||
))
|
||||
Task {
|
||||
try await self.categoryDataStore.save(Category(
|
||||
budgetId: self.budgetId,
|
||||
id: self.categoryId,
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
amount: Int(amount * 100.0),
|
||||
expense: self.type == TransactionType.expense,
|
||||
archived: false
|
||||
))
|
||||
}
|
||||
})
|
||||
}.onDisappear {
|
||||
self.categoryDataStore.clearSelectedCategory()
|
||||
if categoryId.isEmpty {
|
||||
self.categoryDataStore.clearSelectedCategory()
|
||||
}
|
||||
self.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
init(showSheet: Binding<Bool>, category: Category?, budgetId: String) {
|
||||
let initialCategory = category ?? Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false)
|
||||
init(showSheet: Binding<Bool>, category: TwigsCore.Category?, budgetId: String) {
|
||||
let initialCategory = category ?? TwigsCore.Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false)
|
||||
self._showSheet = showSheet
|
||||
self._title = State(initialValue: initialCategory.title)
|
||||
self._description = State(initialValue: initialCategory.description ?? "")
|
||||
|
@ -108,7 +120,7 @@ struct CategoryFormSheet: View {
|
|||
struct CategoryFormSheet_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
CategoryFormSheet(showSheet: .constant(true), category: nil, budgetId: "")
|
||||
.environmentObject(CategoryDataStore(MockCategoryRepository()))
|
||||
.environmentObject(CategoryListDataStore(MockCategoryRepository()))
|
||||
}
|
||||
}
|
||||
#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 Combine
|
||||
import TwigsCore
|
||||
|
||||
struct CategoryListView: View {
|
||||
@EnvironmentObject var categoryDataStore: CategoryDataStore
|
||||
@EnvironmentObject var categoryDataStore: CategoryListDataStore
|
||||
@State var requestId: String = ""
|
||||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch self.categoryDataStore.categories[requestId] {
|
||||
case .success(let categories):
|
||||
Section {
|
||||
List(categories) { category in
|
||||
CategoryListItemView(budget, category: category)
|
||||
}
|
||||
}
|
||||
case .failure(.loading):
|
||||
VStack {
|
||||
ActivityIndicator(isAnimating: .constant(true), style: .large)
|
||||
.onAppear {
|
||||
if requestId == "" {
|
||||
requestId = categoryDataStore.getCategories(budgetId: budget.id, archived: false)
|
||||
InlineLoadingView(
|
||||
action: { try await self.categoryDataStore.getCategories(budgetId: budget.id, expense: nil, archived: nil, count: nil, page: nil) },
|
||||
errorTextLocalizedStringKey: "Failed to load categories"
|
||||
) {
|
||||
if let categories = self.categoryDataStore.categories {
|
||||
List {
|
||||
Section {
|
||||
ForEach(categories.filter { !$0.archived }) { category in
|
||||
CategoryListItemView(budget, category: category)
|
||||
}
|
||||
}
|
||||
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 {
|
||||
var category: Category
|
||||
let category: TwigsCore.Category
|
||||
let budget: Budget
|
||||
@State var sumId: String = ""
|
||||
@State var sum: Int? = nil
|
||||
@EnvironmentObject var transactionDataStore: TransactionDataStore
|
||||
|
||||
var progressTintColor: Color {
|
||||
|
@ -67,31 +64,36 @@ struct CategoryListItemView: View {
|
|||
destination: CategoryDetailsView(category, budget: self.budget)
|
||||
.navigationBarTitle(category.title)
|
||||
) {
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(verbatim: category.title)
|
||||
Spacer()
|
||||
remaining
|
||||
InlineLoadingView(action: {
|
||||
self.sum = try await transactionDataStore.sum(categoryId: category.id)
|
||||
}, errorTextLocalizedStringKey: "Failed to load category balance") {
|
||||
VStack(alignment: .leading) {
|
||||
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 {
|
||||
if self.sumId == "" {
|
||||
self.sumId = transactionDataStore.sum(categoryId: category.id)
|
||||
Task {
|
||||
self.sum = try await transactionDataStore.sum(categoryId: category.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var progressView: ProgressView {
|
||||
var balance: Float = 0.0
|
||||
if case .success(let sum) = transactionDataStore.sums[sumId] {
|
||||
balance = Float(abs(sum.balance))
|
||||
if let sum = self.sum {
|
||||
balance = Float(abs(sum))
|
||||
}
|
||||
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 = ""
|
||||
var color = Color.primary
|
||||
if case .success(let sum) = transactionDataStore.sums[sumId] {
|
||||
let amount = category.amount - abs(sum.balance)
|
||||
if let sum = self.sum {
|
||||
let amount = category.amount - abs(sum)
|
||||
if amount < 0 {
|
||||
remaining = abs(amount).toCurrencyString() + " over budget"
|
||||
if category.expense {
|
||||
|
@ -115,7 +117,7 @@ struct CategoryListItemView: View {
|
|||
return Text(verbatim: remaining).foregroundColor(color)
|
||||
}
|
||||
|
||||
init (_ budget: Budget, category: Category) {
|
||||
init (_ budget: Budget, category: TwigsCore.Category) {
|
||||
self.budget = budget
|
||||
self.category = category
|
||||
}
|
||||
|
|
|
@ -8,18 +8,11 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
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>
|
||||
}
|
||||
import TwigsCore
|
||||
|
||||
#if DEBUG
|
||||
class MockCategoryRepository: CategoryRepository {
|
||||
static let category = Category(
|
||||
static let category = TwigsCore.Category(
|
||||
budgetId: MockBudgetRepository.budget.id,
|
||||
id: "3",
|
||||
title: "Test Category",
|
||||
|
@ -29,24 +22,24 @@ class MockCategoryRepository: CategoryRepository {
|
|||
archived: false
|
||||
)
|
||||
|
||||
func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError> {
|
||||
return Result.Publisher([MockCategoryRepository.category]).eraseToAnyPublisher()
|
||||
func getCategories(budgetId: String?, expense: Bool?, archived: Bool?, count: Int?, page: Int?) async throws -> [TwigsCore.Category] {
|
||||
return [MockCategoryRepository.category]
|
||||
}
|
||||
|
||||
func getCategory(_ categoryId: String) -> AnyPublisher<Category, NetworkError> {
|
||||
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
|
||||
func getCategory(_ categoryId: String) async throws -> TwigsCore.Category {
|
||||
return MockCategoryRepository.category
|
||||
}
|
||||
|
||||
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
||||
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
|
||||
func createCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
|
||||
return MockCategoryRepository.category
|
||||
}
|
||||
|
||||
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
||||
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
|
||||
func updateCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
|
||||
return MockCategoryRepository.category
|
||||
}
|
||||
|
||||
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
|
||||
func deleteCategory(_ id: String) async throws {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -9,6 +9,25 @@
|
|||
import Foundation
|
||||
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 {
|
||||
func toDecimalString() -> String {
|
||||
return String(format: "%.2f", Double(self) / 100.0)
|
||||
|
@ -28,3 +47,18 @@ extension View {
|
|||
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 username: String = ""
|
||||
@State var password: String = ""
|
||||
@EnvironmentObject var userData: AuthenticationDataStore
|
||||
var showLoader: Bool {
|
||||
get {
|
||||
if case self.userData.currentUser = Result<User, UserStatus>.failure(UserStatus.authenticating) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
@EnvironmentObject var dataStore: AuthenticationDataStore
|
||||
|
||||
var body: some View {
|
||||
LoadingView(
|
||||
isShowing: .constant(showLoader),
|
||||
isShowing: $dataStore.loading,
|
||||
loadingText: "loading_login"
|
||||
) {
|
||||
NavigationView {
|
||||
|
@ -44,11 +35,13 @@ struct LoginView: View {
|
|||
.textContentType(UITextContentType.password)
|
||||
.textContentType(.password)
|
||||
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())
|
||||
Spacer()
|
||||
Text("info_register")
|
||||
NavigationLink(destination: RegistrationView(self.userData)) {
|
||||
NavigationLink(destination: RegistrationView(server: self.$server)) {
|
||||
Text("action_register")
|
||||
.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 Combine
|
||||
import TwigsCore
|
||||
|
||||
class TwigsInMemoryCacheService: TwigsApiService {
|
||||
var budgets = Set<Budget>()
|
||||
var categories = Set<Category>()
|
||||
var transactions = Set<Transaction>()
|
||||
private var budgets = Set<Budget>()
|
||||
private var categories = Set<TwigsCore.Category>()
|
||||
private var transactions = Set<Transaction>()
|
||||
|
||||
public init() {
|
||||
super.init(RequestHelper())
|
||||
}
|
||||
|
||||
// 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
|
||||
return first.name < second.name
|
||||
}
|
||||
if results.isEmpty {
|
||||
return super.getBudgets(count: count, page: page).map { (budgets: [Budget]) in
|
||||
self.addBudgets(budgets)
|
||||
return budgets
|
||||
}.eraseToAnyPublisher()
|
||||
let budgets = try await super.getBudgets(count: count, page: page)
|
||||
self.addBudgets(budgets)
|
||||
return budgets
|
||||
}
|
||||
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 {
|
||||
return super.getBudget(id).map { budget in
|
||||
self.addBudget(budget)
|
||||
return budget
|
||||
}.eraseToAnyPublisher()
|
||||
let budget = try await super.getBudget(id)
|
||||
self.addBudget(budget)
|
||||
return budget
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
func addBudget(_ budget: Budget) {
|
||||
private func addBudget(_ budget: Budget) {
|
||||
self.budgets.insert(budget)
|
||||
}
|
||||
|
||||
// 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
|
||||
if budgetId != nil {
|
||||
results = categories.filter { $0.budgetId == budgetId }
|
||||
|
@ -59,56 +83,50 @@ class TwigsInMemoryCacheService: TwigsApiService {
|
|||
results = results.filter { $0.archived == archived }
|
||||
}
|
||||
if results.isEmpty {
|
||||
return super.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page).map { (categories: [Category]) in
|
||||
self.addCategories(categories)
|
||||
return categories
|
||||
}.eraseToAnyPublisher()
|
||||
let categories = try await super.getCategories(budgetId: budgetId, expense: expense, archived: archived, count: count, page: page)
|
||||
self.addCategories(categories)
|
||||
return categories
|
||||
}
|
||||
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 {
|
||||
return super.getCategory(id).map { category in
|
||||
self.addCategory(category)
|
||||
return category
|
||||
}.eraseToAnyPublisher()
|
||||
let category = try await super.getCategory(id)
|
||||
self.addCategory(category)
|
||||
return category
|
||||
}
|
||||
return Result.Publisher(.success(category)).eraseToAnyPublisher()
|
||||
return category
|
||||
}
|
||||
|
||||
func addCategories(_ categories: [Category]) {
|
||||
private func addCategories(_ categories: [TwigsCore.Category]) {
|
||||
categories.forEach { addCategory($0) }
|
||||
}
|
||||
|
||||
func addCategory(_ category: Category) {
|
||||
private func addCategory(_ category: TwigsCore.Category) {
|
||||
self.categories.insert(category)
|
||||
}
|
||||
|
||||
override func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
||||
return super.createCategory(category).map {
|
||||
self.categories.insert(category)
|
||||
return $0
|
||||
}.eraseToAnyPublisher()
|
||||
override func createCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
|
||||
let newCategory = try await super.createCategory(category)
|
||||
self.categories.insert(newCategory)
|
||||
return newCategory
|
||||
}
|
||||
|
||||
override func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
|
||||
return super.updateCategory(category).map {
|
||||
self.removeCategory(category.id)
|
||||
self.categories.insert(category)
|
||||
return $0
|
||||
}.eraseToAnyPublisher()
|
||||
override func updateCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category {
|
||||
let newCategory = try await super.updateCategory(category)
|
||||
self.removeCategory(newCategory.id)
|
||||
self.categories.insert(newCategory)
|
||||
return newCategory
|
||||
}
|
||||
|
||||
override func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return super.deleteCategory(id).map {
|
||||
self.removeCategory(id)
|
||||
return $0
|
||||
}.eraseToAnyPublisher()
|
||||
override func deleteCategory(_ id: String) async throws {
|
||||
try await super.deleteCategory(id)
|
||||
self.removeCategory(id)
|
||||
}
|
||||
|
||||
func removeCategory(_ id: String) {
|
||||
private func removeCategory(_ id: String) {
|
||||
if let index = self.categories.firstIndex(where: { $0.id == id }) {
|
||||
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,14 +7,10 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct ProfileView: View {
|
||||
@EnvironmentObject var authDataStore: AuthenticationDataStore
|
||||
var currentUser: User {
|
||||
get {
|
||||
return try! authDataStore.currentUser.get()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
|
@ -25,7 +21,7 @@ struct ProfileView: View {
|
|||
.clipShape(Circle())
|
||||
.overlay(Circle().stroke(Color.white, lineWidth: 4))
|
||||
.shadow(radius: 5)
|
||||
Text(currentUser.username)
|
||||
Text(authDataStore.currentUser!.username)
|
||||
NavigationLink(destination: EmptyView()) {
|
||||
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 Combine
|
||||
import Collections
|
||||
import TwigsCore
|
||||
|
||||
class RecurringTransactionDataStore: ObservableObject {
|
||||
class RecurringTransactionDataStore: AsyncObservableObject {
|
||||
private let repository: RecurringTransactionsRepository
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
@Published var transactions: Result<[RecurringTransaction], NetworkError>? = nil
|
||||
@Published var transaction: Result<RecurringTransaction, NetworkError>? = nil
|
||||
@Published var transactions: AsyncData<[RecurringTransaction]> = .empty
|
||||
@Published var transaction: AsyncData<RecurringTransaction> = .empty
|
||||
|
||||
init(_ repository: RecurringTransactionsRepository, budgetId: String) {
|
||||
init(_ repository: RecurringTransactionsRepository) {
|
||||
self.repository = repository
|
||||
getRecurringTransactions(budgetId)
|
||||
}
|
||||
|
||||
func getRecurringTransactions(_ budgetId: String) {
|
||||
self.transactions = .failure(.loading)
|
||||
self.currentRequest = self.repository.getRecurringTransactions(budgetId: budgetId)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { (completion) in
|
||||
switch completion {
|
||||
case .finished:
|
||||
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)
|
||||
func getRecurringTransactions(_ budgetId: String) async {
|
||||
self.transactions = .loading
|
||||
do {
|
||||
let transactions = try await self.repository.getRecurringTransactions(budgetId)
|
||||
self.transactions = .success(transactions.sorted(by: { $0.title < $1.title }))
|
||||
} catch {
|
||||
self.transactions = .error(error)
|
||||
}
|
||||
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) {
|
||||
self.transaction = .failure(.loading)
|
||||
func saveRecurringTransaction(_ transaction: RecurringTransaction) async {
|
||||
self.transaction = .loading
|
||||
do {
|
||||
var savedTransaction: RecurringTransaction
|
||||
if (transaction.id != "") {
|
||||
savedTransaction = try await self.repository.updateRecurringTransaction(transaction)
|
||||
} else {
|
||||
savedTransaction = try await self.repository.createRecurringTransaction(transaction)
|
||||
}
|
||||
self.transaction = .success(savedTransaction)
|
||||
if case var .success(transactions) = self.transactions {
|
||||
transactions = transactions.filter(withoutId: savedTransaction.id)
|
||||
transactions.append(savedTransaction)
|
||||
self.transactions = .success(transactions.sorted(by: { $0.title < $1.title }))
|
||||
}
|
||||
} catch {
|
||||
self.transactions = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
self.currentRequest = self.repository.deleteRecurringTransaction(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: { (empty) in
|
||||
self.transaction = .failure(.deleted)
|
||||
if case let .success(transactions) = self.transactions {
|
||||
self.transactions = .success(transactions.filter { $0.id != id })
|
||||
}
|
||||
})
|
||||
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() {
|
||||
self.transaction = nil
|
||||
self.transaction = .empty
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct RecurringTransactionDetailsView: View {
|
||||
let transaction: RecurringTransaction
|
||||
|
|
|
@ -7,20 +7,24 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct RecurringTransactionsListView: View {
|
||||
@ObservedObject var dataStore: RecurringTransactionDataStore
|
||||
let budget: Budget
|
||||
|
||||
var body: some View {
|
||||
switch dataStore.transactions {
|
||||
case .success(let transactions):
|
||||
InlineLoadingView(
|
||||
action: {
|
||||
return try await self.dataStore.getRecurringTransactions(self.budget.id)
|
||||
},
|
||||
errorTextLocalizedStringKey: "Failed to load recurring transactions"
|
||||
) { (transactions: [RecurringTransaction]) in
|
||||
List {
|
||||
ForEach(transactions) { transaction in
|
||||
RecurringTransactionsListItemView(transaction)
|
||||
}
|
||||
}
|
||||
default:
|
||||
ActivityIndicator(isAnimating: .constant(true), style: .medium)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -28,7 +32,7 @@ struct RecurringTransactionsListView: View {
|
|||
#if DEBUG
|
||||
struct RecurringTransactionView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(MockRecurringTransactionRepository(), budgetId: ""))
|
||||
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(MockRecurringTransactionRepository()), budget: MockBudgetRepository.budget)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -8,14 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
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>
|
||||
}
|
||||
import TwigsCore
|
||||
|
||||
#if DEBUG
|
||||
class MockRecurringTransactionRepository: RecurringTransactionsRepository {
|
||||
|
@ -33,24 +26,23 @@ class MockRecurringTransactionRepository: RecurringTransactionsRepository {
|
|||
budgetId: MockBudgetRepository.budget.id
|
||||
)
|
||||
|
||||
func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> {
|
||||
return Result.Publisher([MockRecurringTransactionRepository.transaction]).eraseToAnyPublisher()
|
||||
func getRecurringTransactions(_ budgetId: String) async throws -> [RecurringTransaction] {
|
||||
return [MockRecurringTransactionRepository.transaction]
|
||||
}
|
||||
|
||||
func getRecurringTransaction(_ id: String) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction {
|
||||
return MockRecurringTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
|
||||
return MockRecurringTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher<RecurringTransaction, NetworkError> {
|
||||
return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction {
|
||||
return MockRecurringTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func deleteRecurringTransaction(_ id: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
|
||||
func deleteRecurringTransaction(_ id: String) async throws {
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -9,14 +9,18 @@
|
|||
import SwiftUI
|
||||
|
||||
struct RegistrationView: View {
|
||||
@Binding var server: String
|
||||
@State var username: String = ""
|
||||
@State var email: String = ""
|
||||
@State var password: String = ""
|
||||
@State var confirmedPassword: String = ""
|
||||
@ObservedObject var userData: AuthenticationDataStore
|
||||
@EnvironmentObject var dataStore: AuthenticationDataStore
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
TextField(LocalizedStringKey("prompt_server"), text: self.$server)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.textContentType(.URL)
|
||||
TextField("prompt_username", text: self.$username)
|
||||
.autocapitalization(UITextAutocapitalizationType.none)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
|
@ -32,19 +36,18 @@ struct RegistrationView: View {
|
|||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
.textContentType(UITextContentType.newPassword)
|
||||
Button("action_register", action: {
|
||||
self.userData.register(
|
||||
username: self.username,
|
||||
email: self.email,
|
||||
password: self.password,
|
||||
confirmPassword: self.confirmedPassword
|
||||
)
|
||||
Task {
|
||||
try await self.dataStore.register(
|
||||
server: self.server,
|
||||
username: self.username,
|
||||
email: self.email,
|
||||
password: self.password,
|
||||
confirmPassword: self.confirmedPassword
|
||||
)
|
||||
}
|
||||
}).buttonStyle(DefaultButtonStyle())
|
||||
}.padding()
|
||||
}
|
||||
|
||||
init(_ userData: AuthenticationDataStore) {
|
||||
self.userData = userData
|
||||
}
|
||||
}
|
||||
|
||||
//struct RegistrationView_Previews: PreviewProvider {
|
||||
|
|
|
@ -7,24 +7,20 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct SidebarBudgetView: View {
|
||||
@EnvironmentObject var authenticationDataStore: AuthenticationDataStore
|
||||
@StateObject var budgetDataStore: BudgetsDataStore
|
||||
@EnvironmentObject var budgetDataStore: BudgetsDataStore
|
||||
let apiService: TwigsApiService
|
||||
@State var isSelectingBudget = true
|
||||
@State var hasSelectedBudget = false
|
||||
@State var isAddingTransaction = false
|
||||
@State var tabSelection: Int? = 0
|
||||
|
||||
init(_ apiService: TwigsApiService) {
|
||||
self.apiService = apiService
|
||||
self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainView: some View {
|
||||
if case let .success(budget) = budgetDataStore.budget {
|
||||
if case let .success(budget) = self.budgetDataStore.budget {
|
||||
NavigationView {
|
||||
List {
|
||||
NavigationLink(
|
||||
|
@ -38,7 +34,7 @@ struct SidebarBudgetView: View {
|
|||
NavigationLink(
|
||||
tag: 1,
|
||||
selection: $tabSelection,
|
||||
destination: { TransactionListView(budget).navigationBarTitle("transactions") },
|
||||
destination: { TransactionListView<EmptyView>(budget).navigationBarTitle("transactions") },
|
||||
label: { Label("transactions", systemImage: "dollarsign.circle") })
|
||||
.keyboardShortcut("2")
|
||||
NavigationLink(
|
||||
|
@ -50,14 +46,15 @@ struct SidebarBudgetView: View {
|
|||
NavigationLink(
|
||||
tag: 3,
|
||||
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") })
|
||||
.keyboardShortcut("4")
|
||||
BudgetListsView()
|
||||
}
|
||||
.navigationTitle(budget.name)
|
||||
}.environmentObject(TransactionDataStore(apiService))
|
||||
.environmentObject(CategoryDataStore(apiService))
|
||||
}.navigationViewStyle(.columns)
|
||||
.environmentObject(TransactionDataStore(apiService))
|
||||
.environmentObject(CategoryListDataStore(apiService))
|
||||
.environmentObject(budgetDataStore)
|
||||
.environmentObject(UserDataStore(apiService))
|
||||
} else {
|
||||
|
@ -70,13 +67,17 @@ struct SidebarBudgetView: View {
|
|||
mainView
|
||||
.sheet(isPresented: $authenticationDataStore.showLogin,
|
||||
onDismiss: {
|
||||
self.budgetDataStore.getBudgets()
|
||||
Task {
|
||||
await self.budgetDataStore.getBudgets()
|
||||
}
|
||||
},
|
||||
content: {
|
||||
LoginView()
|
||||
.environmentObject(authenticationDataStore)
|
||||
.onDisappear {
|
||||
self.budgetDataStore.getBudgets()
|
||||
Task {
|
||||
await self.budgetDataStore.getBudgets()
|
||||
}
|
||||
}
|
||||
})
|
||||
.interactiveDismissDisabled(true)
|
||||
|
|
|
@ -7,21 +7,18 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct TabbedBudgetView: View {
|
||||
@EnvironmentObject var authDataStore: AuthenticationDataStore
|
||||
@EnvironmentObject var authenticationDataStore: AuthenticationDataStore
|
||||
@StateObject var budgetDataStore: BudgetsDataStore
|
||||
@EnvironmentObject var budgetDataStore: BudgetsDataStore
|
||||
let apiService: TwigsApiService
|
||||
@State var isSelectingBudget = true
|
||||
@State var hasSelectedBudget = false
|
||||
@State var isAddingTransaction = false
|
||||
@State var tabSelection: Int = 0
|
||||
|
||||
init(_ apiService: TwigsApiService) {
|
||||
self.apiService = apiService
|
||||
self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService))
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
var mainView: some View {
|
||||
if case let .success(budget) = budgetDataStore.budget {
|
||||
|
@ -42,7 +39,7 @@ struct TabbedBudgetView: View {
|
|||
.tag(0)
|
||||
.keyboardShortcut("1")
|
||||
NavigationView {
|
||||
TransactionListView(budget)
|
||||
TransactionListView<EmptyView>(budget)
|
||||
.sheet(isPresented: $isAddingTransaction,
|
||||
onDismiss: {
|
||||
isAddingTransaction = false
|
||||
|
@ -70,7 +67,7 @@ struct TabbedBudgetView: View {
|
|||
.tag(2)
|
||||
.keyboardShortcut("3")
|
||||
NavigationView {
|
||||
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService, budgetId: budget.id))
|
||||
RecurringTransactionsListView(dataStore: RecurringTransactionDataStore(apiService), budget: budget)
|
||||
.navigationBarTitle("recurring_transactions")
|
||||
}
|
||||
.tabItem {
|
||||
|
@ -80,7 +77,7 @@ struct TabbedBudgetView: View {
|
|||
.tag(3)
|
||||
.keyboardShortcut("4")
|
||||
}.environmentObject(TransactionDataStore(apiService))
|
||||
.environmentObject(CategoryDataStore(apiService))
|
||||
.environmentObject(CategoryListDataStore(apiService))
|
||||
.environmentObject(budgetDataStore)
|
||||
.environmentObject(UserDataStore(apiService))
|
||||
} else {
|
||||
|
@ -91,13 +88,17 @@ struct TabbedBudgetView: View {
|
|||
var body: some View {
|
||||
mainView.sheet(isPresented: $authenticationDataStore.showLogin,
|
||||
onDismiss: {
|
||||
self.budgetDataStore.getBudgets()
|
||||
Task {
|
||||
await self.budgetDataStore.getBudgets()
|
||||
}
|
||||
},
|
||||
content: {
|
||||
LoginView()
|
||||
.environmentObject(authenticationDataStore)
|
||||
.onDisappear {
|
||||
self.budgetDataStore.getBudgets()
|
||||
Task {
|
||||
await self.budgetDataStore.getBudgets()
|
||||
}
|
||||
}
|
||||
}).sheet(isPresented: $budgetDataStore.showBudgetSelection,
|
||||
content: {
|
||||
|
|
|
@ -8,11 +8,13 @@
|
|||
|
||||
import SwiftUI
|
||||
import Combine
|
||||
import TwigsCore
|
||||
|
||||
struct AddTransactionView: View {
|
||||
@Binding var showSheet: Bool
|
||||
@EnvironmentObject var authDataStore: AuthenticationDataStore
|
||||
@EnvironmentObject var transactionDataStore: TransactionDataStore
|
||||
@State var loading: Bool = false
|
||||
@State var title: String = ""
|
||||
@State var description: String = ""
|
||||
@State var date: Date = Date()
|
||||
|
@ -22,28 +24,29 @@ struct AddTransactionView: View {
|
|||
@State var categoryId: String = ""
|
||||
var createdBy: String {
|
||||
get {
|
||||
return try! authDataStore.currentUser.get().id
|
||||
return authDataStore.currentUser!.id
|
||||
}
|
||||
}
|
||||
|
||||
var stateContent: AnyView {
|
||||
switch transactionDataStore.transaction {
|
||||
case .success(_):
|
||||
self.showSheet = false
|
||||
return AnyView(EmptyView())
|
||||
case .failure(.loading):
|
||||
return AnyView(EmbeddedLoadingView())
|
||||
default:
|
||||
return AnyView(EditTransactionForm(
|
||||
title: self.$title,
|
||||
description: self.$description,
|
||||
date: self.$date,
|
||||
amount: self.$amount,
|
||||
type: self.$type,
|
||||
budgetId: self.$budgetId,
|
||||
categoryId: self.$categoryId,
|
||||
deleteAction: nil
|
||||
))
|
||||
@ViewBuilder
|
||||
var stateContent: some View {
|
||||
if let _ = transactionDataStore.transaction {
|
||||
EmptyView().onAppear {
|
||||
self.showSheet = false
|
||||
}
|
||||
} else if loading {
|
||||
EmbeddedLoadingView()
|
||||
} else {
|
||||
EditTransactionForm(
|
||||
title: self.$title,
|
||||
description: self.$description,
|
||||
date: self.$date,
|
||||
amount: self.$amount,
|
||||
type: self.$type,
|
||||
budgetId: self.$budgetId,
|
||||
categoryId: self.$categoryId,
|
||||
deleteAction: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -56,21 +59,25 @@ struct AddTransactionView: View {
|
|||
},
|
||||
trailing: Button("save") {
|
||||
let amount = Double(self.amount) ?? 0.0
|
||||
self.transactionDataStore.saveTransaction(Transaction(
|
||||
id: "",
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
date: self.date,
|
||||
amount: Int(amount * 100.0),
|
||||
categoryId: self.categoryId != "" ? self.categoryId : nil,
|
||||
expense: self.type == TransactionType.expense,
|
||||
createdBy: self.createdBy,
|
||||
budgetId: self.budgetId
|
||||
))
|
||||
Task {
|
||||
try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction(
|
||||
id: "",
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
date: self.date,
|
||||
amount: Int(amount * 100.0),
|
||||
categoryId: self.categoryId != "" ? self.categoryId : nil,
|
||||
expense: self.type == TransactionType.expense,
|
||||
createdBy: self.createdBy,
|
||||
budgetId: self.budgetId
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
.onDisappear {
|
||||
_ = self.transactionDataStore.getTransactions(self.budgetId, categoryId: self.categoryId)
|
||||
Task {
|
||||
try await self.transactionDataStore.getTransactions(self.budgetId, categoryId: self.categoryId)
|
||||
}
|
||||
self.transactionDataStore.clearSelectedTransaction()
|
||||
self.title = ""
|
||||
self.description = ""
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct EditTransactionForm: View {
|
||||
@EnvironmentObject var authDataStore: AuthenticationDataStore
|
||||
@Binding var title: String
|
||||
@Binding var description: String
|
||||
@Binding var date: Date
|
||||
|
@ -34,7 +36,7 @@ struct EditTransactionForm: View {
|
|||
}
|
||||
}
|
||||
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 {
|
||||
Button(action: {
|
||||
self.showingAlert = true
|
||||
|
@ -57,14 +59,13 @@ struct BudgetPicker: View {
|
|||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch self.budgetsDataStore.budgets {
|
||||
case .success(let budgets):
|
||||
if let budgets = self.budgetsDataStore.budgets {
|
||||
Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) {
|
||||
ForEach(budgets) { budget in
|
||||
Text(budget.name)
|
||||
}
|
||||
}
|
||||
default:
|
||||
} else {
|
||||
Picker(LocalizedStringKey("prompt_budget"), selection: self.budgetId) {
|
||||
Text("")
|
||||
}
|
||||
|
@ -81,48 +82,32 @@ struct CategoryPicker: View {
|
|||
let budgetId: Binding<String>
|
||||
var categoryId: Binding<String>
|
||||
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
|
||||
var body: some View {
|
||||
switch self.categoryDataStore.categories[requestId] {
|
||||
case .success(let categories):
|
||||
if let categories = self.categoryDataStore.categories {
|
||||
Picker(LocalizedStringKey("prompt_category"), selection: self.categoryId) {
|
||||
ForEach(categories) { category in
|
||||
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 {
|
||||
ActivityIndicator(isAnimating: .constant(true), style: .medium)
|
||||
}.onAppear {
|
||||
if budgetId.wrappedValue != "" {
|
||||
if !self.isRequestIdValid {
|
||||
self.requestId = categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, count: nil, page: nil)
|
||||
}
|
||||
Task {
|
||||
try await self.categoryDataStore.getCategories(budgetId: self.budgetId.wrappedValue, expense: self.expense.wrappedValue == TransactionType.expense, archived: false)
|
||||
}
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
@EnvironmentObject var categoryDataStore: CategoryDataStore
|
||||
init(_ budgetId: Binding<String>, categoryId: Binding<String>, expense: Binding<TransactionType>) {
|
||||
@StateObject var categoryDataStore: CategoryListDataStore
|
||||
init(_ budgetId: Binding<String>, categoryId: Binding<String>, expense: Binding<TransactionType>, apiService: TwigsApiService) {
|
||||
self.budgetId = budgetId
|
||||
self.categoryId = categoryId
|
||||
self.expense = expense
|
||||
self._categoryDataStore = StateObject(wrappedValue: CategoryListDataStore(apiService))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -8,27 +8,9 @@
|
|||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct Transaction: Identifiable, Hashable, Codable {
|
||||
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
|
||||
|
||||
extension TransactionType {
|
||||
var localizedKey: LocalizedStringKey {
|
||||
var key: String
|
||||
switch self {
|
||||
|
@ -39,11 +21,9 @@ enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
|
|||
}
|
||||
return LocalizedStringKey(key)
|
||||
}
|
||||
|
||||
var id: TransactionType { self }
|
||||
}
|
||||
|
||||
extension Transaction {
|
||||
extension TwigsCore.Transaction {
|
||||
var type: TransactionType {
|
||||
if (self.expense) {
|
||||
return .expense
|
||||
|
|
|
@ -9,136 +9,50 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
import Collections
|
||||
import TwigsCore
|
||||
|
||||
class TransactionDataStore: ObservableObject {
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
private var sumRequests: [String:AnyCancellable] = [:]
|
||||
@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)]
|
||||
class TransactionDataStore: AsyncObservableObject {
|
||||
@Published var transactions: AsyncData<OrderedDictionary<String, [Transaction]>> = .empty
|
||||
@Published var transaction: AsyncData<Transaction> = .empty
|
||||
|
||||
func getTransactions(_ budgetId: String, categoryId: String? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) -> String {
|
||||
let requestId = "\(budgetId)-\(categoryId ?? "all")"
|
||||
self.transactions[requestId] = .failure(.loading)
|
||||
|
||||
var categoryIds: [String] = []
|
||||
if let categoryId = categoryId {
|
||||
categoryIds.append(categoryId)
|
||||
func getTransactions(_ budgetId: String, categoryId: String? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) async {
|
||||
try await load {
|
||||
var categoryIds: [String] = []
|
||||
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) {
|
||||
self.transaction = .failure(.loading)
|
||||
|
||||
self.currentRequest = self.transactionRepository.getTransaction(transactionId)
|
||||
.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 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)
|
||||
func saveTransaction(_ transaction: Transaction) async {
|
||||
try await load {
|
||||
if (transaction.id != "") {
|
||||
self.transaction = try await self.transactionRepository.updateTransaction(transaction)
|
||||
} else {
|
||||
self.transaction = try await 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) {
|
||||
self.transaction = .failure(.loading)
|
||||
|
||||
self.currentRequest = self.transactionRepository.deleteTransaction(transactionId)
|
||||
.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 deleteTransaction(_ transactionId: String) async {
|
||||
try await load {
|
||||
try await self.transactionRepository.deleteTransaction(transactionId)
|
||||
self.transaction = nil
|
||||
}
|
||||
}
|
||||
|
||||
func clearSelectedTransaction() {
|
||||
self.transaction = .failure(.unknown)
|
||||
}
|
||||
|
||||
func reset() {
|
||||
self.transaction = .failure(.unknown)
|
||||
self.transactions = ["": .failure(.loading)]
|
||||
self.transaction = nil
|
||||
}
|
||||
|
||||
private let transactionRepository: TransactionRepository
|
||||
|
|
|
@ -7,49 +7,47 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct TransactionDetailsView: View {
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@EnvironmentObject var dataStore: TransactionDataStore
|
||||
@State var shouldNavigateUp: Bool = false
|
||||
let transaction: Transaction
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(transaction.title)
|
||||
.font(.title)
|
||||
Text(transaction.amount.toCurrencyString())
|
||||
.font(.headline)
|
||||
.foregroundColor(transaction.expense ? .red : .green)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Spacer().frame(height: 10)
|
||||
Text(transaction.date.toLocaleString())
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer().frame(height: 20.0)
|
||||
LabeledField(label: "notes", value: transaction.description, showDivider: true)
|
||||
CategoryLineItem(transaction.categoryId)
|
||||
BudgetLineItem()
|
||||
UserLineItem(transaction.createdBy)
|
||||
}.padding()
|
||||
if let transaction = self.dataStore.transaction {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading) {
|
||||
Text(transaction.title)
|
||||
.font(.title)
|
||||
Text(transaction.amount.toCurrencyString())
|
||||
.font(.headline)
|
||||
.foregroundColor(transaction.expense ? .red : .green)
|
||||
.multilineTextAlignment(.trailing)
|
||||
Spacer().frame(height: 10)
|
||||
Text(transaction.date.toLocaleString())
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer().frame(height: 20.0)
|
||||
LabeledField(label: "notes", value: transaction.description, showDivider: true)
|
||||
CategoryLineItem(transaction.categoryId)
|
||||
BudgetLineItem()
|
||||
UserLineItem(transaction.createdBy)
|
||||
}.padding()
|
||||
}
|
||||
.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 {
|
||||
stateContent.onAppear {
|
||||
if let id = self.categoryId {
|
||||
categoryDataStore.getCategory(id)
|
||||
Task {
|
||||
try await categoryDataStore.getCategory(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var stateContent: AnyView {
|
||||
switch categoryDataStore.category {
|
||||
case .success(let category):
|
||||
return AnyView(LabeledField(label: "category", value: category.title, showDivider: true))
|
||||
default:
|
||||
return AnyView(LabeledField(label: "category", value: "", showDivider: true))
|
||||
@ViewBuilder
|
||||
var stateContent: some View {
|
||||
if let category = self.categoryDataStore.category {
|
||||
LabeledField(label: "category", value: category.title, showDivider: true)
|
||||
} else {
|
||||
LabeledField(label: "category", value: "", showDivider: true)
|
||||
}
|
||||
}
|
||||
|
||||
@EnvironmentObject var categoryDataStore: CategoryDataStore
|
||||
@EnvironmentObject var categoryDataStore: CategoryListDataStore
|
||||
let categoryId: String?
|
||||
init(_ categoryId: String?) {
|
||||
self.categoryId = categoryId
|
||||
|
@ -104,35 +104,19 @@ struct CategoryLineItem: View {
|
|||
|
||||
struct BudgetLineItem: View {
|
||||
@EnvironmentObject var budgetDataStore: BudgetsDataStore
|
||||
var budgetName: String {
|
||||
get {
|
||||
if case let .success(budget) = budgetDataStore.budget {
|
||||
return budget.name
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
LabeledField(label: "budget", value: budgetName, showDivider: true)
|
||||
LabeledField(label: "budget", value: self.budgetDataStore.budget?.name, showDivider: true)
|
||||
}
|
||||
}
|
||||
|
||||
struct UserLineItem: View {
|
||||
|
||||
var body: some View {
|
||||
stateContent.onAppear {
|
||||
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))
|
||||
LabeledField(label: "registered_by", value: userDataStore.user?.username, showDivider: false).onAppear {
|
||||
Task {
|
||||
try await userDataStore.getUser(userId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,7 +130,7 @@ struct UserLineItem: View {
|
|||
#if DEBUG
|
||||
struct TransactionDetailsView_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
TransactionDetailsView(MockTransactionRepository.transaction)
|
||||
TransactionDetailsView()
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -7,8 +7,10 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
struct TransactionEditView: View {
|
||||
@State var loading: Bool = false
|
||||
@Environment(\.presentationMode) var presentationMode
|
||||
@State var title: String
|
||||
@State var description: String
|
||||
|
@ -19,22 +21,16 @@ struct TransactionEditView: View {
|
|||
@State var categoryId: String
|
||||
var createdBy: String {
|
||||
get {
|
||||
try! authDataStore.currentUser.get().id
|
||||
return authDataStore.currentUser!.id
|
||||
}
|
||||
}
|
||||
let id: String?
|
||||
var shouldNavigateUp: Binding<Bool>
|
||||
|
||||
var stateContent: AnyView {
|
||||
switch transactionDataStore.transaction {
|
||||
case .success(_), .failure(.deleted):
|
||||
self.shouldNavigateUp.wrappedValue = true
|
||||
self.presentationMode.wrappedValue.dismiss()
|
||||
return AnyView(EmptyView())
|
||||
case .failure(.loading):
|
||||
return AnyView(EmbeddedLoadingView())
|
||||
default:
|
||||
return AnyView(EditTransactionForm(
|
||||
@ViewBuilder
|
||||
var stateContent: some View {
|
||||
if let _ = self.transactionDataStore.transaction {
|
||||
EditTransactionForm(
|
||||
title: self.$title,
|
||||
description: self.$description,
|
||||
date: self.$date,
|
||||
|
@ -43,9 +39,16 @@ struct TransactionEditView: View {
|
|||
budgetId: self.$budgetId,
|
||||
categoryId: self.$categoryId,
|
||||
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
|
||||
.navigationBarItems(trailing: Button("save") {
|
||||
let amount = Double(self.amount) ?? 0.0
|
||||
self.transactionDataStore.saveTransaction(Transaction(
|
||||
id: self.id ?? "",
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
date: self.date,
|
||||
amount: Int(amount * 100.0),
|
||||
categoryId: self.categoryId,
|
||||
expense: self.type == TransactionType.expense,
|
||||
createdBy: self.createdBy,
|
||||
budgetId: self.budgetId
|
||||
))
|
||||
Task {
|
||||
try await self.transactionDataStore.saveTransaction(TwigsCore.Transaction(
|
||||
id: self.id ?? "",
|
||||
title: self.title,
|
||||
description: self.description,
|
||||
date: self.date,
|
||||
amount: Int(amount * 100.0),
|
||||
categoryId: self.categoryId,
|
||||
expense: self.type == TransactionType.expense,
|
||||
createdBy: self.createdBy,
|
||||
budgetId: self.budgetId
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@EnvironmentObject var transactionDataStore: TransactionDataStore
|
||||
@EnvironmentObject var authDataStore: AuthenticationDataStore
|
||||
init(_ transaction: Transaction, shouldNavigateUp: Binding<Bool>) {
|
||||
init(_ transaction: TwigsCore.Transaction, shouldNavigateUp: Binding<Bool>) {
|
||||
self.id = transaction.id
|
||||
self._title = State<String>(initialValue: transaction.title)
|
||||
self._description = State<String>(initialValue: transaction.description ?? "")
|
||||
|
|
|
@ -9,22 +9,24 @@
|
|||
import SwiftUI
|
||||
import Combine
|
||||
import Collections
|
||||
import TwigsCore
|
||||
|
||||
struct TransactionListView: View {
|
||||
struct TransactionListView<Content>: View where Content: View {
|
||||
@EnvironmentObject var transactionDataStore: TransactionDataStore
|
||||
@State var requestId: String = ""
|
||||
@State var isAddingTransaction = false
|
||||
@State var search: String = ""
|
||||
let header: AnyView?
|
||||
@ViewBuilder
|
||||
let header: (() -> Content)?
|
||||
|
||||
@ViewBuilder
|
||||
private func TransactionList(_ transactions: OrderedDictionary<String, [Transaction]>) -> some View {
|
||||
private func TransactionList(_ transactions: OrderedDictionary<String, [TwigsCore.Transaction]>) -> some View {
|
||||
if transactions.isEmpty {
|
||||
Text("no_transactions")
|
||||
} else {
|
||||
if let header = header {
|
||||
Section {
|
||||
header
|
||||
header()
|
||||
}
|
||||
}
|
||||
ForEach(transactions.keys, id: \.self) { (key: String) in
|
||||
|
@ -47,8 +49,11 @@ struct TransactionListView: View {
|
|||
|
||||
@ViewBuilder
|
||||
var body: some View {
|
||||
switch transactionDataStore.transactions[requestId] {
|
||||
case .success(let transactions):
|
||||
InlineLoadingView(
|
||||
action: { try await transactionDataStore.getTransactions(self.budget.id, categoryId: self.category?.id) },
|
||||
errorTextLocalizedStringKey: "Failed to load transactions"
|
||||
) {
|
||||
if let transactions = self.transactionDataStore.transactions {
|
||||
List {
|
||||
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 category: Category?
|
||||
init(_ budget: Budget, category: Category? = nil, header: AnyView? = nil) {
|
||||
let category: TwigsCore.Category?
|
||||
init(_ budget: Budget, category: TwigsCore.Category? = nil, header: (() -> Content)? = nil) {
|
||||
self.budget = budget
|
||||
self.category = category
|
||||
self.header = header
|
||||
|
@ -94,38 +86,44 @@ struct TransactionListView: View {
|
|||
}
|
||||
|
||||
struct TransactionListItemView: View {
|
||||
var transaction: Transaction
|
||||
@EnvironmentObject var dataStore: TransactionDataStore
|
||||
var transaction: TwigsCore.Transaction
|
||||
|
||||
var body: some View {
|
||||
NavigationLink(
|
||||
destination: TransactionDetailsView(transaction)
|
||||
.navigationBarTitle("details", displayMode: .inline)
|
||||
) {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(verbatim: transaction.title)
|
||||
.lineLimit(1)
|
||||
.font(.headline)
|
||||
if let description = transaction.description?.trimmingCharacters(in: CharacterSet([" "])), !description.isEmpty {
|
||||
Text(verbatim: description)
|
||||
tag: self.transaction,
|
||||
selection: self.$dataStore.transaction,
|
||||
destination: {
|
||||
TransactionDetailsView().navigationBarTitle("details", displayMode: .inline)
|
||||
},
|
||||
label: {
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(verbatim: transaction.title)
|
||||
.lineLimit(1)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
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)
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing) {
|
||||
Text(verbatim: transaction.amount.toCurrencyString())
|
||||
.foregroundColor(transaction.expense ? .red : .green)
|
||||
.multilineTextAlignment(.trailing)
|
||||
}
|
||||
.padding(.leading)
|
||||
}.padding(5.0)
|
||||
}
|
||||
.padding(.leading)
|
||||
}.padding(5.0)
|
||||
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
init (_ transaction: Transaction) {
|
||||
init (_ transaction: TwigsCore.Transaction) {
|
||||
self.transaction = transaction
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,15 +8,7 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
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>
|
||||
}
|
||||
import TwigsCore
|
||||
|
||||
#if DEBUG
|
||||
class MockTransactionRepository: TransactionRepository {
|
||||
|
@ -32,28 +24,28 @@ class MockTransactionRepository: TransactionRepository {
|
|||
budgetId: MockBudgetRepository.budget.id
|
||||
)
|
||||
|
||||
func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) -> AnyPublisher<[Transaction], NetworkError> {
|
||||
return Result.Publisher([MockTransactionRepository.transaction]).eraseToAnyPublisher()
|
||||
func getTransactions(budgetIds: [String], categoryIds: [String]?, from: Date?, to: Date?, count: Int?, page: Int?) async throws -> [Transaction] {
|
||||
return [MockTransactionRepository.transaction]
|
||||
}
|
||||
|
||||
func getTransaction(_ transactionId: String) -> AnyPublisher<Transaction, NetworkError> {
|
||||
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func getTransaction(_ transactionId: String) async throws -> Transaction {
|
||||
return MockTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
|
||||
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func createTransaction(_ transaction: Transaction) async throws -> Transaction {
|
||||
return MockTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> {
|
||||
return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher()
|
||||
func updateTransaction(_ transaction: Transaction) async throws -> Transaction {
|
||||
return MockTransactionRepository.transaction
|
||||
}
|
||||
|
||||
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> {
|
||||
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
|
||||
func deleteTransaction(_ transactionId: String) async throws {
|
||||
// Do nothing
|
||||
}
|
||||
|
||||
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError> {
|
||||
return Result.Publisher(.success(BalanceResponse(balance: 1000))).eraseToAnyPublisher()
|
||||
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) async throws -> BalanceResponse {
|
||||
return BalanceResponse(balance: 1000)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
|
|
@ -7,31 +7,24 @@
|
|||
//
|
||||
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
@main
|
||||
struct TwigsApp: App {
|
||||
@StateObject var authDataStore: AuthenticationDataStore
|
||||
let apiService: TwigsApiService = TwigsInMemoryCacheService()
|
||||
|
||||
init() {
|
||||
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)
|
||||
}
|
||||
}
|
||||
@AppStorage("BASE_URL") var baseUrl: String = ""
|
||||
@AppStorage("TOKEN") var token: String = ""
|
||||
@AppStorage("USER_ID") var userId: String = ""
|
||||
let apiService: TwigsInMemoryCacheService = TwigsInMemoryCacheService()
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
mainView
|
||||
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 Combine
|
||||
import SwiftUI
|
||||
import TwigsCore
|
||||
|
||||
class AuthenticationDataStore: ObservableObject {
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
@Published var currentUser: Result<User, UserStatus> = .failure(.unauthenticated)
|
||||
var showLogin: Bool {
|
||||
get {
|
||||
switch currentUser {
|
||||
case .success(_):
|
||||
print("Authenticated")
|
||||
return false
|
||||
@Published var loading: Bool = false {
|
||||
didSet {
|
||||
print("authDataStore loading: \(self.loading)")
|
||||
}
|
||||
}
|
||||
@Published var currentUser: User? = nil {
|
||||
didSet {
|
||||
self.showLogin = self.currentUser == nil
|
||||
}
|
||||
}
|
||||
@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 login(server: String, username: String, password: String) async throws {
|
||||
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("Unauthenticated")
|
||||
return true
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
return
|
||||
}
|
||||
set { }
|
||||
self.token = response.token
|
||||
self.userId = response.userId
|
||||
try await self.loadProfile()
|
||||
}
|
||||
|
||||
func login(server: String, username: String, password: String) {
|
||||
// Changes the status and notifies any observers of the change
|
||||
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()
|
||||
func register(server: String, username: String, email: String, password: String, confirmPassword: String) async throws {
|
||||
self.loading = true
|
||||
defer {
|
||||
self.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
func register(username: String, email: String, password: String, confirmPassword: String) {
|
||||
self.currentUser = .failure(.authenticating)
|
||||
|
||||
// TODO: Validate other fields as well
|
||||
if !password.elementsEqual(confirmPassword) {
|
||||
self.currentUser = .failure(.passwordMismatch)
|
||||
// TODO: Show error message to user
|
||||
return
|
||||
}
|
||||
|
||||
currentRequest = self.userRepository.register(username: username, email: email, password: password)
|
||||
.receive(on: DispatchQueue.main)
|
||||
.sink(receiveCompletion: { (status) in
|
||||
switch status {
|
||||
case .finished:
|
||||
return
|
||||
case .failure( _):
|
||||
self.currentUser = .failure(.failedAuthentication)
|
||||
}
|
||||
}) { (user) in
|
||||
self.currentUser = .success(user)
|
||||
}
|
||||
}
|
||||
|
||||
private func loadProfile() {
|
||||
guard let userId = UserDefaults.standard.string(forKey: USER_ID) else {
|
||||
self.currentUser = .failure(.unauthenticated)
|
||||
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 ?? ""
|
||||
do {
|
||||
_ = try await apiService.register(username: username, email: email, password: password)
|
||||
} catch {
|
||||
switch error {
|
||||
case NetworkError.jsonParsingFailed(let jsonError):
|
||||
print(jsonError.localizedDescription)
|
||||
default:
|
||||
print(error.localizedDescription)
|
||||
}
|
||||
return
|
||||
}
|
||||
guard let token = UserDefaults.standard.string(forKey: TOKEN) else {
|
||||
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)
|
||||
}
|
||||
try await self.login(server: server, username: username, password: password)
|
||||
}
|
||||
|
||||
init(_ userRepository: UserRepository) {
|
||||
self.userRepository = userRepository
|
||||
if UserDefaults.standard.string(forKey: TOKEN) != nil {
|
||||
loadProfile()
|
||||
func loadProfile() async throws {
|
||||
self.loading = true
|
||||
defer {
|
||||
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 USER_ID = "USER_ID"
|
||||
|
||||
|
@ -114,14 +103,5 @@ enum UserStatus: Error, Equatable {
|
|||
case authenticating
|
||||
case failedAuthentication
|
||||
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 Combine
|
||||
import TwigsCore
|
||||
|
||||
class UserDataStore: ObservableObject {
|
||||
private var currentRequest: AnyCancellable? = nil
|
||||
@Published var user: Result<User, NetworkError> = .failure(.loading)
|
||||
class UserDataStore: AsyncObservableObject {
|
||||
@Published var user: AsyncData<User> = .empty
|
||||
|
||||
func getUser(_ id: String) {
|
||||
self.user = .failure(.loading)
|
||||
|
||||
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
|
||||
func getUser(_ id: String) async {
|
||||
do {
|
||||
let user = try await self.userRepository.getUser(id)
|
||||
self.user = .success(user)
|
||||
})
|
||||
|
||||
} catch {
|
||||
self.user = .error(error)
|
||||
}
|
||||
}
|
||||
|
||||
private let userRepository: UserRepository
|
||||
|
|
|
@ -8,18 +8,9 @@
|
|||
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
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>
|
||||
}
|
||||
import TwigsCore
|
||||
|
||||
#if DEBUG
|
||||
|
||||
class MockUserRepository: UserRepository {
|
||||
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)
|
||||
|
@ -29,27 +20,23 @@ class MockUserRepository: UserRepository {
|
|||
MockUserRepository.token = token
|
||||
}
|
||||
|
||||
func getUser(_ id: String) -> AnyPublisher<User, NetworkError> {
|
||||
return Result<User, NetworkError>.Publisher(MockUserRepository.user)
|
||||
.eraseToAnyPublisher()
|
||||
func getUser(_ id: String) async throws -> User {
|
||||
return MockUserRepository.user
|
||||
}
|
||||
|
||||
func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> {
|
||||
return Result<[User], NetworkError>.Publisher([MockUserRepository.user])
|
||||
.eraseToAnyPublisher()
|
||||
func searchUsers(_ withUsername: String) async throws -> [User] {
|
||||
return [MockUserRepository.user]
|
||||
}
|
||||
|
||||
func setServer(_ server: String) {
|
||||
}
|
||||
|
||||
func login(username: String, password: String) -> AnyPublisher<LoginResponse, NetworkError> {
|
||||
return Result<LoginResponse, NetworkError>.Publisher(MockUserRepository.loginResponse)
|
||||
.eraseToAnyPublisher()
|
||||
func login(username: String, password: String) async throws -> LoginResponse {
|
||||
return MockUserRepository.loginResponse
|
||||
}
|
||||
|
||||
func register(username: String, email: String, password: String) -> AnyPublisher<User, NetworkError> {
|
||||
return Result<User, NetworkError>.Publisher(MockUserRepository.user)
|
||||
.eraseToAnyPublisher()
|
||||
func register(username: String, email: String, password: String) async throws -> User {
|
||||
return MockUserRepository.user
|
||||
}
|
||||
}
|
||||
|
||||
|
|
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