diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index 7dcc551..44c60dc 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -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 = ""; }; 282126BC235CDE1400072D52 /* ProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressView.swift; sourceTree = ""; }; 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabbedBudgetView.swift; sourceTree = ""; }; - 2841022B2342D8E400EAFA29 /* Budget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Budget.swift; sourceTree = ""; }; 2841022F2342D97300EAFA29 /* BudgetListsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetListsView.swift; sourceTree = ""; }; 284102312342E12F00EAFA29 /* CategoryListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListView.swift; sourceTree = ""; }; 2857EAEC233DA30B0026BC83 /* LoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadingView.swift; sourceTree = ""; }; - 2888234623512DBF003D3847 /* Observable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Observable.swift; sourceTree = ""; }; 289510232352AAFC00BC862B /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.swift; sourceTree = ""; }; 28A1E959235006A300CA57FE /* AddTransactionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddTransactionView.swift; sourceTree = ""; }; 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 = ""; }; 28AC9511233C373A00BFB70A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 28AC9520233C381C00BFB70A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 28AC9524233C42D100BFB70A /* TwigsApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApiService.swift; sourceTree = ""; }; 28AC9528233C433400BFB70A /* TransactionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRepository.swift; sourceTree = ""; }; 28AC952B233C434800BFB70A /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = ""; }; - 28AC952D233C43A300BFB70A /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegistrationView.swift; sourceTree = ""; }; 28CE8B9423525F990072BC4C /* Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Extensions.swift; sourceTree = ""; }; 28FE6AF32342E3CB00D5543E /* BudgetsDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetsDataStore.swift; sourceTree = ""; }; 28FE6AF52342E4CC00D5543E /* BudgetRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BudgetRepository.swift; sourceTree = ""; }; - 28FE6AF723441E1D00D5543E /* Category.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Category.swift; sourceTree = ""; }; - 28FE6AF923441E3700D5543E /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = ""; }; + 28FE6AF923441E3700D5543E /* CategoryListDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryListDataStore.swift; sourceTree = ""; }; 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryRepository.swift; sourceTree = ""; }; 28FE6AFF2344308600D5543E /* Transaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transaction.swift; sourceTree = ""; }; 28FE6B012344331B00D5543E /* TransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDataStore.swift; sourceTree = ""; }; 28FE6B03234449DC00D5543E /* TransactionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionListView.swift; sourceTree = ""; }; 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = ""; }; + 8005FD54277E61DC00E48B23 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 8005FD5C277EAB0200E48B23 /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + 800DFC2B277FF47A00EDCE9B /* AsyncData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncData.swift; sourceTree = ""; }; 801D08CB275ECEFA00931465 /* RecurringTransactionsListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsListView.swift; sourceTree = ""; }; 801D08CD275F189E00931465 /* RecurringTransactionsRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionsRepository.swift; sourceTree = ""; }; 801D08CF275F1AE300931465 /* RecurringTransactionDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDataStore.swift; sourceTree = ""; }; 801D08D1275FB7DE00931465 /* RecurringTransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransactionDetailsView.swift; sourceTree = ""; }; + 802161CF277647920075761A /* AsyncObservableObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncObservableObject.swift; sourceTree = ""; }; 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = ""; }; + 8044BA3827828E9D009A78D4 /* CategoryDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDataStore.swift; sourceTree = ""; }; 806C784F272B700B00FA1375 /* TwigsApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApp.swift; sourceTree = ""; }; 80820144275FFD380040996E /* SidebarBudgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarBudgetView.swift; sourceTree = ""; }; + 808582CD277E5E9E00006859 /* TwigsCore */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = TwigsCore; path = ../TwigsCore; sourceTree = ""; }; 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = ""; }; 809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; - 80D6B1F0275B11DE0075D0EC /* RecurringTransaction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecurringTransaction.swift; sourceTree = ""; }; + 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineLoadingView.swift; sourceTree = ""; }; /* 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 = ""; @@ -194,6 +195,8 @@ children = ( 2857EAEC233DA30B0026BC83 /* LoadingView.swift */, 282126BC235CDE1400072D52 /* ProgressView.swift */, + 80D1FC13277C1EF9007F17FB /* InlineLoadingView.swift */, + 8005FD5C277EAB0200E48B23 /* MainView.swift */, ); path = Views; sourceTree = ""; @@ -201,10 +204,13 @@ 28AC94E1233C373900BFB70A = { isa = PBXGroup; children = ( + 80DBED432774AE4F00CB0A88 /* Packages */, + 8005FD53277E61DC00E48B23 /* twigs-cli */, 28AC94EB233C373900BFB70A /* Products */, 28AC94EC233C373900BFB70A /* Twigs */, 28AC9503233C373A00BFB70A /* TwigsTests */, 28AC950E233C373A00BFB70A /* TwigsUITests */, + 8005FD59277E623900E48B23 /* Frameworks */, ); sourceTree = ""; }; @@ -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 = ""; @@ -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 = ""; }; + 8005FD53277E61DC00E48B23 /* twigs-cli */ = { + isa = PBXGroup; + children = ( + 8005FD54277E61DC00E48B23 /* main.swift */, + ); + path = "twigs-cli"; + sourceTree = ""; + }; + 8005FD59277E623900E48B23 /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; 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 = ""; }; + 80DBED432774AE4F00CB0A88 /* Packages */ = { + isa = PBXGroup; + children = ( + 808582CD277E5E9E00006859 /* TwigsCore */, + ); + name = Packages; + sourceTree = ""; + }; /* 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" */; diff --git a/Twigs/AsyncData.swift b/Twigs/AsyncData.swift new file mode 100644 index 0000000..262327b --- /dev/null +++ b/Twigs/AsyncData.swift @@ -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: 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 + } + } +} diff --git a/Twigs/AsyncObservableObject.swift b/Twigs/AsyncObservableObject.swift new file mode 100644 index 0000000..53f73e5 --- /dev/null +++ b/Twigs/AsyncObservableObject.swift @@ -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(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 + } + } +} diff --git a/Twigs/Budget/Budget.swift b/Twigs/Budget/Budget.swift deleted file mode 100644 index 53bb8e8..0000000 --- a/Twigs/Budget/Budget.swift +++ /dev/null @@ -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 -} diff --git a/Twigs/Budget/BudgetDetailsView.swift b/Twigs/Budget/BudgetDetailsView.swift index c5ec496..e7ce0c7 100644 --- a/Twigs/Budget/BudgetDetailsView.swift +++ b/Twigs/Budget/BudgetDetailsView.swift @@ -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) } } } diff --git a/Twigs/Budget/BudgetListsView.swift b/Twigs/Budget/BudgetListsView.swift index 45ff46f..4b4cf63 100644 --- a/Twigs/Budget/BudgetListsView.swift +++ b/Twigs/Budget/BudgetListsView.swift @@ -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() - }) } } } diff --git a/Twigs/Budget/BudgetRepository.swift b/Twigs/Budget/BudgetRepository.swift index 3f9a6a4..600d140 100644 --- a/Twigs/Budget/BudgetRepository.swift +++ b/Twigs/Budget/BudgetRepository.swift @@ -8,14 +8,7 @@ import Foundation import Combine - -protocol BudgetRepository { - func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError> - func getBudget(_ id: String) -> AnyPublisher - func newBudget(_ budget: Budget) -> AnyPublisher - func updateBudget(_ budget: Budget) -> AnyPublisher - func deleteBudget(_ id: String) -> AnyPublisher -} +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 { - return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() + func getBudget(_ id: String) async throws -> Budget { + return MockBudgetRepository.budget } - func newBudget(_ budget: Budget) -> AnyPublisher { - return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() + func newBudget(_ budget: Budget) async throws -> Budget { + return MockBudgetRepository.budget } - func updateBudget(_ budget: Budget) -> AnyPublisher { - 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 { - return Result.Publisher(Empty()).eraseToAnyPublisher() + func deleteBudget(_ id: String) async throws { } } #endif diff --git a/Twigs/Budget/BudgetsDataStore.swift b/Twigs/Budget/BudgetsDataStore.swift index 4f486cb..7547c24 100644 --- a/Twigs/Budget/BudgetsDataStore.swift +++ b/Twigs/Budget/BudgetsDataStore.swift @@ -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? = .failure(.loading) { + @Published var budgets: AsyncData<[Budget]> = .empty + @Published var budget: AsyncData = .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 = .failure(.loading) + @Published var overview: AsyncData = .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] = [] - 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" diff --git a/Twigs/Category/Category.swift b/Twigs/Category/Category.swift deleted file mode 100644 index e5e80c4..0000000 --- a/Twigs/Category/Category.swift +++ /dev/null @@ -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 -} diff --git a/Twigs/Category/CategoryDataStore.swift b/Twigs/Category/CategoryDataStore.swift index cd73281..68c5194 100644 --- a/Twigs/Category/CategoryDataStore.swift +++ b/Twigs/Category/CategoryDataStore.swift @@ -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 = .failure(.unknown) + @Published var sum: AsyncData = .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 - 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) + } } } diff --git a/Twigs/Category/CategoryDetailsView.swift b/Twigs/Category/CategoryDetailsView.swift index b70a282..b079cc5 100644 --- a/Twigs/Category/CategoryDetailsView.swift +++ b/Twigs/Category/CategoryDetailsView.swift @@ -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 } diff --git a/Twigs/Category/CategoryFormSheet.swift b/Twigs/Category/CategoryFormSheet.swift index e3edd8d..bbe84fe 100644 --- a/Twigs/Category/CategoryFormSheet.swift +++ b/Twigs/Category/CategoryFormSheet.swift @@ -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, category: Category?, budgetId: String) { - let initialCategory = category ?? Category(budgetId: budgetId, id: "", title: "", description: "", amount: 0, expense: true, archived: false) + init(showSheet: Binding, 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 diff --git a/Twigs/Category/CategoryListDataStore.swift b/Twigs/Category/CategoryListDataStore.swift new file mode 100644 index 0000000..cde8287 --- /dev/null +++ b/Twigs/Category/CategoryListDataStore.swift @@ -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 = .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 + } +} diff --git a/Twigs/Category/CategoryListView.swift b/Twigs/Category/CategoryListView.swift index 8a2bb9a..aa44f36 100644 --- a/Twigs/Category/CategoryListView.swift +++ b/Twigs/Category/CategoryListView.swift @@ -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 } diff --git a/Twigs/Category/CategoryRepository.swift b/Twigs/Category/CategoryRepository.swift index 1d59ce7..17acfe7 100644 --- a/Twigs/Category/CategoryRepository.swift +++ b/Twigs/Category/CategoryRepository.swift @@ -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 - func createCategory(_ category: Category) -> AnyPublisher - func updateCategory(_ category: Category) -> AnyPublisher - func deleteCategory(_ id: String) -> AnyPublisher -} +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 { - return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() + func getCategory(_ categoryId: String) async throws -> TwigsCore.Category { + return MockCategoryRepository.category } - func createCategory(_ category: Category) -> AnyPublisher { - return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() + func createCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category { + return MockCategoryRepository.category } - func updateCategory(_ category: Category) -> AnyPublisher { - return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() + func updateCategory(_ category: TwigsCore.Category) async throws -> TwigsCore.Category { + return MockCategoryRepository.category } - func deleteCategory(_ id: String) -> AnyPublisher { - return Result.Publisher(.success(Empty())).eraseToAnyPublisher() + func deleteCategory(_ id: String) async throws { + } } diff --git a/Twigs/Extensions.swift b/Twigs/Extensions.swift index 1c2c6b4..5c8888f 100644 --- a/Twigs/Extensions.swift +++ b/Twigs/Extensions.swift @@ -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 + } +} diff --git a/Twigs/LoginView.swift b/Twigs/LoginView.swift index 865c923..2678c42 100644 --- a/Twigs/LoginView.swift +++ b/Twigs/LoginView.swift @@ -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.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()) } diff --git a/Twigs/Network/TwigsApiService.swift b/Twigs/Network/TwigsApiService.swift deleted file mode 100644 index f12bc28..0000000 --- a/Twigs/Network/TwigsApiService.swift +++ /dev/null @@ -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]() - 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 { - return requestHelper.get("/api/budgets/\(id)") - } - - func newBudget(_ budget: Budget) -> AnyPublisher { - return requestHelper.post("/api/budgets", data: budget, type: Budget.self) - } - - func updateBudget(_ budget: Budget) -> AnyPublisher { - return requestHelper.put("/api/budgets/\(budget.id)", data: budget) - } - - func deleteBudget(_ id: String) -> AnyPublisher { - 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]() - 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 { - return requestHelper.get("/api/transactions/\(id)") - } - - func createTransaction(_ transaction: Transaction) -> AnyPublisher { - return requestHelper.post("/api/transactions", data: transaction, type: Transaction.self) - } - - func updateTransaction(_ transaction: Transaction) -> AnyPublisher { - return requestHelper.put("/api/transactions/\(transaction.id)", data: transaction) - } - - func deleteTransaction(_ id: String) -> AnyPublisher { - return requestHelper.delete("/api/transactions/\(id)") - } - - func sumTransactions(budgetId: String? = nil, categoryId: String? = nil, from: Date? = nil, to: Date? = nil) -> AnyPublisher { - var queries = [String: Array]() - 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]() - 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 { - return requestHelper.get("/api/categories/\(id)") - } - - func getCategoryBalance(_ id: String) -> AnyPublisher { - return requestHelper.get("/api/categories/\(id)/balance") - } - - func createCategory(_ category: Category) -> AnyPublisher { - return requestHelper.post("/api/categories", data: category, type: Category.self) - } - - func updateCategory(_ category: Category) -> AnyPublisher { - return requestHelper.put("/api/categories/\(category.id)", data: category) - } - - func deleteCategory(_ id: String) -> AnyPublisher { - return requestHelper.delete("/api/categories/\(id)") - } - - // MARK: Users - func login(username: String, password: String) -> AnyPublisher { - 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 { - 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 { - 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]() - 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 { - return requestHelper.post("/api/users", data: user, type: User.self) - } - - func updateUser(_ user: User) -> AnyPublisher { - return requestHelper.put("/api/users/\(user.id)", data: user) - } - - func deleteUser(_ user: User) -> AnyPublisher { - 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 { - return requestHelper.get("/api/recurringtransactions/\(id)") - } - - func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { - return requestHelper.post("/api/recurringtransactions", data: transaction, type: RecurringTransaction.self) - } - - func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { - return requestHelper.put("/api/recurringtransactions/\(transaction.id)", data: transaction) - } - - func deleteRecurringTransaction(_ id: String) -> AnyPublisher { - 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( - _ endPoint: String, - queries: [String: Array]? = nil - ) -> AnyPublisher { - 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( - _ endPoint: String, - data: Codable, - type: ResultType.Type - ) -> AnyPublisher { - return buildRequest( - endPoint: endPoint, - method: "POST", - data: data - ) - } - - func put( - _ endPoint: String, - data: ResultType - ) -> AnyPublisher { - return buildRequest( - endPoint: endPoint, - method: "PUT", - data: data - ) - } - - func delete(_ endPoint: String) -> AnyPublisher { - // 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( - endPoint: String, - method: String, - data: Encodable? = nil - ) -> AnyPublisher { - - 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) - } -} diff --git a/Twigs/Network/TwigsInMemoryCacheService.swift b/Twigs/Network/TwigsInMemoryCacheService.swift index 4342856..5282ea9 100644 --- a/Twigs/Network/TwigsInMemoryCacheService.swift +++ b/Twigs/Network/TwigsInMemoryCacheService.swift @@ -7,47 +7,71 @@ // import Foundation -import Combine +import TwigsCore class TwigsInMemoryCacheService: TwigsApiService { - var budgets = Set() - var categories = Set() - var transactions = Set() + private var budgets = Set() + private var categories = Set() + private var transactions = Set() + 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 { + 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 { + 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 { - 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 { - 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 { - 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) } diff --git a/Twigs/Observable.swift b/Twigs/Observable.swift deleted file mode 100644 index 846a524..0000000 --- a/Twigs/Observable.swift +++ /dev/null @@ -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: ObservableObject, Identifiable { - let id = UUID() - let objectWillChange = ObservableObjectPublisher() - let publisher = PassthroughSubject() - var value: T { - didSet { - objectWillChange.send() - publisher.send(value) - } - } - - init(_ initValue: T) { self.value = initValue } -} diff --git a/Twigs/Profile/ProfileView.swift b/Twigs/Profile/ProfileView.swift index 7a46307..a7d6253 100644 --- a/Twigs/Profile/ProfileView.swift +++ b/Twigs/Profile/ProfileView.swift @@ -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") } diff --git a/Twigs/Recurring Transactions/RecurringTransaction.swift b/Twigs/Recurring Transactions/RecurringTransaction.swift deleted file mode 100644 index faa22a1..0000000 --- a/Twigs/Recurring Transactions/RecurringTransaction.swift +++ /dev/null @@ -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) - 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) - } -} diff --git a/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift index c719bba..70fc5d7 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionDataStore.swift @@ -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? = nil + @Published var transactions: AsyncData<[RecurringTransaction]> = .empty + @Published var transaction: AsyncData = .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 - 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 } } diff --git a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift index f20cb7e..8fce7ca 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionDetailsView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import TwigsCore struct RecurringTransactionDetailsView: View { let transaction: RecurringTransaction diff --git a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift index aaef2bc..51d5620 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionsListView.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionsListView.swift @@ -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 diff --git a/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift index f3aae0e..55cb912 100644 --- a/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift +++ b/Twigs/Recurring Transactions/RecurringTransactionsRepository.swift @@ -8,14 +8,7 @@ import Foundation import Combine - -protocol RecurringTransactionsRepository { - func getRecurringTransactions(budgetId: String) -> AnyPublisher<[RecurringTransaction], NetworkError> - func getRecurringTransaction(_ id: String) -> AnyPublisher - func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher - func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher - func deleteRecurringTransaction(_ id: String) -> AnyPublisher -} +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 { - return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + func getRecurringTransaction(_ id: String) async throws -> RecurringTransaction { + return MockRecurringTransactionRepository.transaction } - func createRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { - return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + func createRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction { + return MockRecurringTransactionRepository.transaction } - func updateRecurringTransaction(_ transaction: RecurringTransaction) -> AnyPublisher { - return Result.Publisher(MockRecurringTransactionRepository.transaction).eraseToAnyPublisher() + func updateRecurringTransaction(_ transaction: RecurringTransaction) async throws -> RecurringTransaction { + return MockRecurringTransactionRepository.transaction } - func deleteRecurringTransaction(_ id: String) -> AnyPublisher { - return Result.Publisher(.success(Empty())).eraseToAnyPublisher() + func deleteRecurringTransaction(_ id: String) async throws { } } #endif diff --git a/Twigs/RegistrationView.swift b/Twigs/RegistrationView.swift index dbc726d..34b9bc9 100644 --- a/Twigs/RegistrationView.swift +++ b/Twigs/RegistrationView.swift @@ -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 { diff --git a/Twigs/SidebarBudgetView.swift b/Twigs/SidebarBudgetView.swift index 3ea9986..3193415 100644 --- a/Twigs/SidebarBudgetView.swift +++ b/Twigs/SidebarBudgetView.swift @@ -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(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) diff --git a/Twigs/TabbedBudgetView.swift b/Twigs/TabbedBudgetView.swift index eaf09c2..9f1f7c2 100644 --- a/Twigs/TabbedBudgetView.swift +++ b/Twigs/TabbedBudgetView.swift @@ -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(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: { diff --git a/Twigs/Transaction/AddTransactionView.swift b/Twigs/Transaction/AddTransactionView.swift index c32999a..d8bd6f1 100644 --- a/Twigs/Transaction/AddTransactionView.swift +++ b/Twigs/Transaction/AddTransactionView.swift @@ -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 = "" diff --git a/Twigs/Transaction/EditTransactionForm.swift b/Twigs/Transaction/EditTransactionForm.swift index b89561d..19a0cae 100644 --- a/Twigs/Transaction/EditTransactionForm.swift +++ b/Twigs/Transaction/EditTransactionForm.swift @@ -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 var categoryId: Binding let expense: Binding - @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, categoryId: Binding, expense: Binding) { + @StateObject var categoryDataStore: CategoryListDataStore + init(_ budgetId: Binding, categoryId: Binding, expense: Binding, apiService: TwigsApiService) { self.budgetId = budgetId self.categoryId = categoryId self.expense = expense + self._categoryDataStore = StateObject(wrappedValue: CategoryListDataStore(apiService)) } } diff --git a/Twigs/Transaction/Transaction.swift b/Twigs/Transaction/Transaction.swift index e63a795..2740851 100644 --- a/Twigs/Transaction/Transaction.swift +++ b/Twigs/Transaction/Transaction.swift @@ -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 diff --git a/Twigs/Transaction/TransactionDataStore.swift b/Twigs/Transaction/TransactionDataStore.swift index ae78861..d1bf9d6 100644 --- a/Twigs/Transaction/TransactionDataStore.swift +++ b/Twigs/Transaction/TransactionDataStore.swift @@ -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, NetworkError>] = ["": .failure(.loading)] - @Published var transaction: Result = .failure(.unknown) - @Published var sums: [String:Result] = ["": .failure(.loading)] +class TransactionDataStore: AsyncObservableObject { + @Published var transactions: AsyncData> = .empty + @Published var transaction: AsyncData = .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(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(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 - 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 diff --git a/Twigs/Transaction/TransactionDetailsView.swift b/Twigs/Transaction/TransactionDetailsView.swift index abdf103..8897073 100644 --- a/Twigs/Transaction/TransactionDetailsView.swift +++ b/Twigs/Transaction/TransactionDetailsView.swift @@ -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 diff --git a/Twigs/Transaction/TransactionEditView.swift b/Twigs/Transaction/TransactionEditView.swift index 18e0a00..67bbc3e 100644 --- a/Twigs/Transaction/TransactionEditView.swift +++ b/Twigs/Transaction/TransactionEditView.swift @@ -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 - 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) { + init(_ transaction: TwigsCore.Transaction, shouldNavigateUp: Binding) { self.id = transaction.id self._title = State(initialValue: transaction.title) self._description = State(initialValue: transaction.description ?? "") diff --git a/Twigs/Transaction/TransactionListView.swift b/Twigs/Transaction/TransactionListView.swift index 32eee7b..81d0978 100644 --- a/Twigs/Transaction/TransactionListView.swift +++ b/Twigs/Transaction/TransactionListView.swift @@ -9,22 +9,24 @@ import SwiftUI import Combine import Collections +import TwigsCore -struct TransactionListView: View { +struct TransactionListView: 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) -> some View { + private func TransactionList(_ transactions: OrderedDictionary) -> 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 } } diff --git a/Twigs/Transaction/TransactionRepository.swift b/Twigs/Transaction/TransactionRepository.swift index 411f604..8369313 100644 --- a/Twigs/Transaction/TransactionRepository.swift +++ b/Twigs/Transaction/TransactionRepository.swift @@ -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 - func createTransaction(_ transaction: Transaction) -> AnyPublisher - func updateTransaction(_ transaction: Transaction) -> AnyPublisher - func deleteTransaction(_ transactionId: String) -> AnyPublisher - func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher -} +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 { - return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() + func getTransaction(_ transactionId: String) async throws -> Transaction { + return MockTransactionRepository.transaction } - func createTransaction(_ transaction: Transaction) -> AnyPublisher { - return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() + func createTransaction(_ transaction: Transaction) async throws -> Transaction { + return MockTransactionRepository.transaction } - func updateTransaction(_ transaction: Transaction) -> AnyPublisher { - return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() + func updateTransaction(_ transaction: Transaction) async throws -> Transaction { + return MockTransactionRepository.transaction } - func deleteTransaction(_ transactionId: String) -> AnyPublisher { - 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 { - 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 diff --git a/Twigs/TwigsApp.swift b/Twigs/TwigsApp.swift index c4fa393..2e73a36 100644 --- a/Twigs/TwigsApp.swift +++ b/Twigs/TwigsApp.swift @@ -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 + } + } } } } diff --git a/Twigs/User/AuthenticationDataStore.swift b/Twigs/User/AuthenticationDataStore.swift index e33de18..7f3b65c 100644 --- a/Twigs/User/AuthenticationDataStore.swift +++ b/Twigs/User/AuthenticationDataStore.swift @@ -1,111 +1,100 @@ import Foundation import Combine import SwiftUI +import TwigsCore class AuthenticationDataStore: ObservableObject { - private var currentRequest: AnyCancellable? = nil - @Published var currentUser: Result = .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, token: Binding, userId: Binding) { + 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 diff --git a/Twigs/User/User.swift b/Twigs/User/User.swift deleted file mode 100644 index 88a6cf8..0000000 --- a/Twigs/User/User.swift +++ /dev/null @@ -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 -} diff --git a/Twigs/User/UserDataStore.swift b/Twigs/User/UserDataStore.swift index cd0914a..9152f4e 100644 --- a/Twigs/User/UserDataStore.swift +++ b/Twigs/User/UserDataStore.swift @@ -8,29 +8,18 @@ import Foundation import Combine +import TwigsCore -class UserDataStore: ObservableObject { - private var currentRequest: AnyCancellable? = nil - @Published var user: Result = .failure(.loading) +class UserDataStore: AsyncObservableObject { + @Published var user: AsyncData = .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 diff --git a/Twigs/User/UserRepository.swift b/Twigs/User/UserRepository.swift index 543fc50..04998be 100644 --- a/Twigs/User/UserRepository.swift +++ b/Twigs/User/UserRepository.swift @@ -8,18 +8,9 @@ import Foundation import Combine - -protocol UserRepository { - func setToken(_ token: String) - func getUser(_ id: String) -> AnyPublisher - func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> - func setServer(_ server: String) - func login(username: String, password: String) -> AnyPublisher - func register(username: String, email: String, password: String) -> AnyPublisher -} +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 { - return Result.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 { - return Result.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 { - return Result.Publisher(MockUserRepository.user) - .eraseToAnyPublisher() + func register(username: String, email: String, password: String) async throws -> User { + return MockUserRepository.user } } diff --git a/Twigs/Views/InlineLoadingView.swift b/Twigs/Views/InlineLoadingView.swift new file mode 100644 index 0000000..1efb282 --- /dev/null +++ b/Twigs/Views/InlineLoadingView.swift @@ -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: View where Content: View, Data: Equatable { + @Binding var data: AsyncData + 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 diff --git a/Twigs/Views/MainView.swift b/Twigs/Views/MainView.swift new file mode 100644 index 0000000..59d7ecd --- /dev/null +++ b/Twigs/Views/MainView.swift @@ -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, token: Binding, userId: Binding) { + 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("")) + } +}