WIP: Convert codebase to async/await

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

View file

@ -14,11 +14,9 @@
282126BB235CDD3C00072D52 /* BudgetDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 282126BA235CDD3C00072D52 /* BudgetDetailsView.swift */; };
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
View file

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

View file

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

View file

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

View file

@ -7,6 +7,7 @@
//
import SwiftUI
import 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)
}
}
}

View file

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

View file

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

View file

@ -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())
if category.expense {
budgetOverview.actualExpenses += abs(response.balance)
} else {
budgetOverview.actualIncome += response.balance
}
}
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)
})
})
}
self.overview = .success(budgetOverview)
} catch {
self.overview = .error(error)
}
}
func selectBudget(_ budget: Budget) {
self.budget = .success(budget)
}
}
private let LAST_BUDGET = "LAST_BUDGET"

View file

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

View file

@ -1,112 +1,45 @@
//
// CategoryDataStore.swift
// 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)
}
}
}

View file

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

View file

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

View file

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

View file

@ -8,35 +8,32 @@
import SwiftUI
import 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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -7,47 +7,71 @@
//
import Foundation
import 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)
}

View file

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

View file

@ -7,15 +7,11 @@
//
import SwiftUI
import TwigsCore
struct ProfileView: View {
@EnvironmentObject var authDataStore: AuthenticationDataStore
var currentUser: User {
get {
return try! authDataStore.currentUser.get()
}
}
var body: some View {
VStack(spacing: 10) {
Image(systemName: "person.circle.fill")
@ -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")
}

View file

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

View file

@ -9,103 +9,61 @@
import Foundation
import 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)
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 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)
}
}
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
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,38 +104,22 @@ 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)
LabeledField(label: "registered_by", value: userDataStore.user?.username, showDivider: false).onAppear {
Task {
try await userDataStore.getUser(userId)
}
}
}
var stateContent: AnyView {
switch userDataStore.user {
case .success(let user):
return AnyView(LabeledField(label: "registered_by", value: user.username, showDivider: false))
default:
return AnyView(LabeledField(label: "registered_by", value: "", showDivider: false))
}
}
@EnvironmentObject var userDataStore: UserDataStore
let userId: String
init(_ userId: String) {
@ -146,7 +130,7 @@ struct UserLineItem: View {
#if DEBUG
struct TransactionDetailsView_Previews: PreviewProvider {
static var previews: some View {
TransactionDetailsView(MockTransactionRepository.transaction)
TransactionDetailsView()
}
}
#endif

View file

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

View file

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

View file

@ -8,18 +8,10 @@
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 {
class MockTransactionRepository: TransactionRepository {
static let transaction: Transaction = Transaction(
id: "2",
title: "Test Transaction",
@ -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

View file

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

View file

@ -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
default:
print("Unauthenticated")
return true
}
@Published var loading: Bool = false {
didSet {
print("authDataStore loading: \(self.loading)")
}
set { }
}
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()
@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 register(username: String, email: String, password: String, confirmPassword: String) {
self.currentUser = .failure(.authenticating)
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(error.localizedDescription)
}
return
}
self.token = response.token
self.userId = response.userId
try await self.loadProfile()
}
func register(server: String, username: String, email: String, password: String, confirmPassword: String) async throws {
self.loading = true
defer {
self.loading = false
}
// TODO: Validate other fields as well
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

View file

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

View file

@ -8,29 +8,18 @@
import Foundation
import 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

View file

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

View file

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

View file

@ -0,0 +1,50 @@
//
// MainView.swift
// Twigs
//
// Created by William Brawner on 12/30/21.
// Copyright © 2021 William Brawner. All rights reserved.
//
import SwiftUI
import TwigsCore
struct MainView: View {
@StateObject var authenticationDataStore: AuthenticationDataStore
@StateObject var budgetDataStore: BudgetsDataStore
let apiService: TwigsApiService
init(_ apiService: TwigsApiService, baseUrl: Binding<String>, token: Binding<String>, userId: Binding<String>) {
self.apiService = apiService
self._authenticationDataStore = StateObject(wrappedValue: AuthenticationDataStore(apiService, baseUrl: baseUrl, token: token, userId: userId))
self._budgetDataStore = StateObject(wrappedValue: BudgetsDataStore(budgetRepository: apiService, categoryRepository: apiService, transactionRepository: apiService))
}
@ViewBuilder
var mainView: some View {
if UIDevice.current.userInterfaceIdiom == .mac || UIDevice.current.userInterfaceIdiom == .pad {
SidebarBudgetView(apiService: apiService)
.environmentObject(authenticationDataStore)
.environmentObject(budgetDataStore)
} else {
TabbedBudgetView(apiService: apiService)
.environmentObject(authenticationDataStore)
.environmentObject(budgetDataStore)
}
}
var body: some View {
mainView.onAppear {
print("MainView.onAppear")
Task {
try await self.authenticationDataStore.loadProfile()
}
}
}
}
struct MainView_Previews: PreviewProvider {
static var previews: some View {
MainView(TwigsInMemoryCacheService(), baseUrl: .constant(""), token: .constant(""), userId: .constant(""))
}
}