From 8fbd43ec5f23511f29c9b5639b31236b6f55862a Mon Sep 17 00:00:00 2001 From: Billy Brawner Date: Sat, 12 Oct 2019 18:22:09 -0700 Subject: [PATCH] WIP: Implement TransactionDetailsView Signed-off-by: Billy Brawner --- BudgetApp.xcodeproj/project.pbxproj | 16 +- .../xcdebugger/Breakpoints_v2.xcbkptlist | 445 ++++++++++++++++++ BudgetApp/Budget/BudgetRepository.swift | 28 +- BudgetApp/Budget/BudgetsDataStore.swift | 20 +- BudgetApp/Category/CategoryDataStore.swift | 23 + BudgetApp/Category/CategoryRepository.swift | 19 +- BudgetApp/ContentView.swift | 10 +- BudgetApp/DataStoreProvider.swift | 14 +- BudgetApp/Extensions.swift | 20 + BudgetApp/LoginView.swift | 4 +- BudgetApp/RegistrationView.swift | 4 +- BudgetApp/TabbedBudgetView.swift | 4 +- .../Transaction/AddTransactionView.swift | 5 +- .../Transaction/TransactionDetailsView.swift | 134 +++++- .../Transaction/TransactionListView.swift | 11 +- .../Transaction/TransactionRepository.swift | 50 +- BudgetApp/User/AuthenticationDataStore.swift | 80 ++++ BudgetApp/User/UserDataStore.swift | 86 +--- BudgetApp/User/UserRepository.swift | 22 +- BudgetApp/en.lproj/Localizable.strings | 10 + BudgetApp/es-419.lproj/Localizable.strings | 10 + 21 files changed, 849 insertions(+), 166 deletions(-) create mode 100644 BudgetApp/Extensions.swift create mode 100644 BudgetApp/User/AuthenticationDataStore.swift diff --git a/BudgetApp.xcodeproj/project.pbxproj b/BudgetApp.xcodeproj/project.pbxproj index 217fb79..c000a9b 100644 --- a/BudgetApp.xcodeproj/project.pbxproj +++ b/BudgetApp.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 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 */; }; 28AC94EE233C373900BFB70A /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94ED233C373900BFB70A /* AppDelegate.swift */; }; 28AC94F0233C373900BFB70A /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28AC94EF233C373900BFB70A /* SceneDelegate.swift */; }; @@ -29,6 +30,7 @@ 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 */; }; @@ -39,7 +41,7 @@ 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 /* UserDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* UserDataStore.swift */; }; + 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -67,6 +69,7 @@ 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 /* BudgetApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = BudgetApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 28AC94ED233C373900BFB70A /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -90,6 +93,7 @@ 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 = ""; }; @@ -100,7 +104,7 @@ 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 /* UserDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDataStore.swift; sourceTree = ""; }; + 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -200,6 +204,7 @@ 28AC94F5233C373A00BFB70A /* Preview Content */, 28FE6AFD234428BF00D5543E /* DataStoreProvider.swift */, 2888234623512DBF003D3847 /* Observable.swift */, + 28CE8B9423525F990072BC4C /* Extensions.swift */, ); path = BudgetApp; sourceTree = ""; @@ -256,7 +261,8 @@ children = ( 28AC952B233C434800BFB70A /* UserRepository.swift */, 28AC952D233C43A300BFB70A /* User.swift */, - 543ECE41233E82A40018A9D9 /* UserDataStore.swift */, + 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */, + 289510232352AAFC00BC862B /* UserDataStore.swift */, ); path = User; sourceTree = ""; @@ -413,14 +419,16 @@ 28B9E50E2346BCB2007C3909 /* RegistrationView.swift in Sources */, 284102322342E12F00EAFA29 /* CategoryListView.swift in Sources */, 284102252341998300EAFA29 /* ContentView.swift in Sources */, + 289510242352AAFC00BC862B /* UserDataStore.swift in Sources */, 28FE6B002344308600D5543E /* Transaction.swift in Sources */, 28FE6AF823441E1D00D5543E /* Category.swift in Sources */, 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */, - 543ECE42233E82A40018A9D9 /* UserDataStore.swift in Sources */, + 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */, 284102302342D97300EAFA29 /* BudgetListsView.swift in Sources */, 28AC952E233C43A300BFB70A /* User.swift in Sources */, + 28CE8B9523525F990072BC4C /* Extensions.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/BudgetApp.xcodeproj/xcuserdata/billy.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/BudgetApp.xcodeproj/xcuserdata/billy.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist index a11feb2..e4b42ad 100644 --- a/BudgetApp.xcodeproj/xcuserdata/billy.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist +++ b/BudgetApp.xcodeproj/xcuserdata/billy.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist @@ -881,5 +881,450 @@ landmarkType = "3"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/BudgetApp/Budget/BudgetRepository.swift b/BudgetApp/Budget/BudgetRepository.swift index abcb8f5..149361c 100644 --- a/BudgetApp/Budget/BudgetRepository.swift +++ b/BudgetApp/Budget/BudgetRepository.swift @@ -53,22 +53,19 @@ class NetworkBudgetRepository: BudgetRepository { #if DEBUG class MockBudgetRepository: BudgetRepository { + static let budget = Budget( + id: 1, + name: "Test Budget", + description: "A mock budget used for testing", + users: [] + ) + func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError> { - return Result.Publisher([Budget( - id: 1, - name: "Test Budget", - description: "A mock budget used for testing", - users: [] - )]).eraseToAnyPublisher() + return Result.Publisher([MockBudgetRepository.budget]).eraseToAnyPublisher() } func getBudget(_ id: Int) -> AnyPublisher { - return Result.Publisher(Budget( - id: 1, - name: "Test Budget", - description: "A mock budget used for testing", - users: [] - )).eraseToAnyPublisher() + return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() } func getBudgetBalance(_ id: Int) -> AnyPublisher { @@ -76,12 +73,7 @@ class MockBudgetRepository: BudgetRepository { } func newBudget(_ budget: Budget) -> AnyPublisher { - return Result.Publisher(Budget( - id: 1, - name: "Test Budget", - description: "A mock budget used for testing", - users: [] - )).eraseToAnyPublisher() + return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() } func updateBudget(_ budget: Budget) -> AnyPublisher { diff --git a/BudgetApp/Budget/BudgetsDataStore.swift b/BudgetApp/Budget/BudgetsDataStore.swift index ec14192..c4f40b2 100644 --- a/BudgetApp/Budget/BudgetsDataStore.swift +++ b/BudgetApp/Budget/BudgetsDataStore.swift @@ -20,7 +20,7 @@ class BudgetsDataStore: ObservableObject { self.objectWillChange.send() } } - + func getBudgets(count: Int? = nil, page: Int? = nil) { self.budgets = .failure(.loading) @@ -39,6 +39,24 @@ class BudgetsDataStore: ObservableObject { }) } + func getBudget(_ id: Int) { + self.budget = .failure(.loading) + + _ = self.budgetRepository.getBudget(id) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (status) in + switch status { + case .finished: + return + case .failure(let error): + self.budget = .failure(error) + return + } + }, receiveValue: { (budget) in + self.budget = .success(budget) + }) + } + init(_ budgetRepository: BudgetRepository) { self.budgetRepository = budgetRepository } diff --git a/BudgetApp/Category/CategoryDataStore.swift b/BudgetApp/Category/CategoryDataStore.swift index 8091fa3..6da4b78 100644 --- a/BudgetApp/Category/CategoryDataStore.swift +++ b/BudgetApp/Category/CategoryDataStore.swift @@ -16,6 +16,12 @@ class CategoryDataStore: ObservableObject { } } + var category: Result = .failure(.loading) { + didSet { + self.objectWillChange.send() + } + } + func getCategories(budgetId: Int? = nil, count: Int? = nil, page: Int? = nil) { self.categories = .failure(.loading) @@ -33,6 +39,23 @@ class CategoryDataStore: ObservableObject { }) } + func getCategory(_ categoryId: Int) { + self.category = .failure(.loading) + + _ = categoryRepository.getCategory(categoryId) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (completion) in + switch completion { + case .finished: + return + case .failure(let error): + self.category = .failure(error) + } + }, receiveValue: { (categories) in + self.category = .success(categories) + }) + } + let objectWillChange = ObservableObjectPublisher() private let categoryRepository: CategoryRepository init(_ categoryRepository: CategoryRepository) { diff --git a/BudgetApp/Category/CategoryRepository.swift b/BudgetApp/Category/CategoryRepository.swift index 1fa34fc..350235d 100644 --- a/BudgetApp/Category/CategoryRepository.swift +++ b/BudgetApp/Category/CategoryRepository.swift @@ -11,6 +11,7 @@ import Combine protocol CategoryRepository { func getCategories(budgetId: Int?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError> + func getCategory(_ categoryId: Int) -> AnyPublisher } class NetworkCategoryRepository: CategoryRepository { @@ -23,13 +24,29 @@ class NetworkCategoryRepository: CategoryRepository { func getCategories(budgetId: Int?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError> { return apiService.getCategories(budgetId: budgetId, count: count, page: page) } + + func getCategory(_ categoryId: Int) -> AnyPublisher { + return apiService.getCategory(categoryId) + } } #if DEBUG class MockCategoryRepository: CategoryRepository { + static let category = Category( + budgetId: MockBudgetRepository.budget.id!, + id: 3, + title: "Test Category", + description: "This is a test category to help with testing", + amount: 10000 + ) + func getCategories(budgetId: Int?, count: Int?, page: Int?) -> AnyPublisher<[Category], NetworkError> { - return Result.Publisher([]).eraseToAnyPublisher() + return Result.Publisher([MockCategoryRepository.category]).eraseToAnyPublisher() + } + + func getCategory(_ categoryId: Int) -> AnyPublisher { + return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() } } diff --git a/BudgetApp/ContentView.swift b/BudgetApp/ContentView.swift index bee3efa..cfccff9 100644 --- a/BudgetApp/ContentView.swift +++ b/BudgetApp/ContentView.swift @@ -9,7 +9,7 @@ import SwiftUI struct ContentView: View { - @ObservedObject var userData: UserDataStore + @ObservedObject var authenticationDataStore: AuthenticationDataStore var body: some View { stateContent @@ -17,14 +17,14 @@ struct ContentView: View { var stateContent: AnyView { if showLogin() { - return AnyView(LoginView(userData)) + return AnyView(LoginView(authenticationDataStore)) } else { - return AnyView(TabbedBudgetView(userData, dataStoreProvider: dataStoreProvider)) + return AnyView(TabbedBudgetView(authenticationDataStore, dataStoreProvider: dataStoreProvider)) } } func showLogin() -> Bool { - switch userData.currentUser { + switch authenticationDataStore.currentUser { case .failure: return true default: @@ -36,7 +36,7 @@ struct ContentView: View { init (_ dataStoreProvider: DataStoreProvider) { self.dataStoreProvider = dataStoreProvider - self.userData = dataStoreProvider.userDataStore() + self.authenticationDataStore = dataStoreProvider.authenticationDataStore() } } diff --git a/BudgetApp/DataStoreProvider.swift b/BudgetApp/DataStoreProvider.swift index 8740c87..21d402f 100644 --- a/BudgetApp/DataStoreProvider.swift +++ b/BudgetApp/DataStoreProvider.swift @@ -15,8 +15,9 @@ class DataStoreProvider { private let budgetRepository: BudgetRepository private let categoryRepository: CategoryRepository private let transactionRepository: TransactionRepository - - private let _userDataStore: UserDataStore + private let userRepository: UserRepository + + private let _authenticationDataStore: AuthenticationDataStore func budgetsDataStore() -> BudgetsDataStore { return BudgetsDataStore(budgetRepository) @@ -30,8 +31,12 @@ class DataStoreProvider { return TransactionDataStore(transactionRepository) } + func authenticationDataStore() -> AuthenticationDataStore { + return self._authenticationDataStore + } + func userDataStore() -> UserDataStore { - return self._userDataStore + return UserDataStore(userRepository) } init( @@ -43,7 +48,8 @@ class DataStoreProvider { self.budgetRepository = budgetRepository self.categoryRepository = categoryRepository self.transactionRepository = transactionRepository - self._userDataStore = UserDataStore(userRepository) + self.userRepository = userRepository + self._authenticationDataStore = AuthenticationDataStore(userRepository) } } diff --git a/BudgetApp/Extensions.swift b/BudgetApp/Extensions.swift new file mode 100644 index 0000000..4e3fe36 --- /dev/null +++ b/BudgetApp/Extensions.swift @@ -0,0 +1,20 @@ +// +// Extensions.swift +// BudgetApp +// +// Created by Billy Brawner on 10/12/19. +// Copyright © 2019 William Brawner. All rights reserved. +// + +import Foundation + +extension Int { + func toCurrencyString() -> String? { + let currencyFormatter = NumberFormatter() + currencyFormatter.locale = Locale.current + currencyFormatter.numberStyle = .currency + let doubleSelf = Double(self) / 100.0 + return currencyFormatter.string(from: NSNumber(value: doubleSelf)) + } +} + diff --git a/BudgetApp/LoginView.swift b/BudgetApp/LoginView.swift index 680db88..93f4411 100644 --- a/BudgetApp/LoginView.swift +++ b/BudgetApp/LoginView.swift @@ -12,7 +12,7 @@ import Combine struct LoginView: View { @State var username: String = "" @State var password: String = "" - @ObservedObject var userData: UserDataStore + @ObservedObject var userData: AuthenticationDataStore let showLoader: Bool var body: some View { @@ -43,7 +43,7 @@ struct LoginView: View { } } - init (_ userData: UserDataStore) { + init (_ userData: AuthenticationDataStore) { self.userData = userData if case userData.currentUser = Result.failure(UserStatus.authenticating) { self.showLoader = true diff --git a/BudgetApp/RegistrationView.swift b/BudgetApp/RegistrationView.swift index ba17da0..dbc726d 100644 --- a/BudgetApp/RegistrationView.swift +++ b/BudgetApp/RegistrationView.swift @@ -13,7 +13,7 @@ struct RegistrationView: View { @State var email: String = "" @State var password: String = "" @State var confirmedPassword: String = "" - @ObservedObject var userData: UserDataStore + @ObservedObject var userData: AuthenticationDataStore var body: some View { VStack { @@ -42,7 +42,7 @@ struct RegistrationView: View { }.padding() } - init(_ userData: UserDataStore) { + init(_ userData: AuthenticationDataStore) { self.userData = userData } } diff --git a/BudgetApp/TabbedBudgetView.swift b/BudgetApp/TabbedBudgetView.swift index e902722..aeb2408 100644 --- a/BudgetApp/TabbedBudgetView.swift +++ b/BudgetApp/TabbedBudgetView.swift @@ -9,7 +9,7 @@ import SwiftUI struct TabbedBudgetView: View { - @ObservedObject var userData: UserDataStore + @ObservedObject var userData: AuthenticationDataStore @State var isAddingTransaction = false var body: some View { @@ -49,7 +49,7 @@ struct TabbedBudgetView: View { } let dataStoreProvider: DataStoreProvider - init (_ userData: UserDataStore, dataStoreProvider: DataStoreProvider) { + init (_ userData: AuthenticationDataStore, dataStoreProvider: DataStoreProvider) { self.userData = userData self.dataStoreProvider = dataStoreProvider } diff --git a/BudgetApp/Transaction/AddTransactionView.swift b/BudgetApp/Transaction/AddTransactionView.swift index abf8881..5e2b832 100644 --- a/BudgetApp/Transaction/AddTransactionView.swift +++ b/BudgetApp/Transaction/AddTransactionView.swift @@ -93,14 +93,13 @@ struct AddTransactionView: View { }, trailing: Button("save") { let amount = Double(self.amount) ?? 0.0 - self.transactionDataStore.createTransaction(Transaction( id: self.id, title: self.title, description: self.description, date: self.date, amount: Int(amount * 100.0), - categoryId: self.categoryId!, + categoryId: self.categoryId, expense: self.type == TransactionType.expense, createdBy: self.createdBy, budgetId: self.budgetPublisher.value! @@ -119,7 +118,7 @@ struct AddTransactionView: View { self.budgetsDataStore = budgetsDataStore let categoryDataStore = dataStoreProvider.categoryDataStore() self.categoryDataStore = categoryDataStore - self.createdBy = try! dataStoreProvider.userDataStore().currentUser.get().id! + self.createdBy = try! dataStoreProvider.authenticationDataStore().currentUser.get().id! } } diff --git a/BudgetApp/Transaction/TransactionDetailsView.swift b/BudgetApp/Transaction/TransactionDetailsView.swift index 6a93170..cb50ed8 100644 --- a/BudgetApp/Transaction/TransactionDetailsView.swift +++ b/BudgetApp/Transaction/TransactionDetailsView.swift @@ -16,26 +16,136 @@ struct TransactionDetailsView: View { var stateContent: AnyView { switch transactionDataStore.transaction { case .success(let transaction): - return AnyView(VStack { - Text(transaction.title) - }) + return AnyView(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(self.dataStoreProvider, categoryId: transaction.categoryId) + BudgetLineItem(self.dataStoreProvider, budgetId: transaction.budgetId) + UserLineItem(self.dataStoreProvider, userId: transaction.createdBy) + }.padding() + } + .navigationBarItems(trailing: NavigationLink(destination: EmptyView()) { + Text("edit") + })) case .failure(.loading): return AnyView(EmbeddedLoadingView()) default: return AnyView(Text("transaction_details_error")) } } - - let transactionDataStore: TransactionDataStore - init(_ dataStoreProvider: DataStoreProvider, transaction: Transaction) { + + let dataStoreProvider: DataStoreProvider + @ObservedObject var transactionDataStore: TransactionDataStore + init(_ dataStoreProvider: DataStoreProvider, transactionId: Int) { + self.dataStoreProvider = dataStoreProvider let transactionDataStore = dataStoreProvider.transactionDataStore() - transactionDataStore.getTransactions() + transactionDataStore.getTransaction(transactionId) self.transactionDataStore = transactionDataStore } } -//struct TransactionDetailsView_Previews: PreviewProvider { -// static var previews: some View { -// TransactionDetailsView() -// } -//} +struct LabeledField: View { + let label: LocalizedStringKey + let value: String? + let showDivider: Bool + + var body: some View { + VStack { + HStack { + Text(self.label) + .foregroundColor(.secondary) + Spacer() + Text(verbatim: value ?? "") + .multilineTextAlignment(.trailing) + } + if showDivider { + Divider() + } + } + } +} + +struct CategoryLineItem: View { + var body: some View { + stateContent + } + + 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)) + } + } + + @ObservedObject var categoryDataStore: CategoryDataStore + init(_ dataStoreProvider: DataStoreProvider, categoryId: Int?) { + let categoryDataStore = dataStoreProvider.categoryDataStore() + if let id = categoryId { + categoryDataStore.getCategory(id) + } + self.categoryDataStore = categoryDataStore + } +} + +struct BudgetLineItem: View { + var body: some View { + stateContent + } + + var stateContent: AnyView { + switch budgetDataStore.budget { + case .success(let budget): + return AnyView(LabeledField(label: "budget", value: budget.name, showDivider: true)) + default: + return AnyView(LabeledField(label: "budget", value: "", showDivider: true)) + } + } + + @ObservedObject var budgetDataStore: BudgetsDataStore + init(_ dataStoreProvider: DataStoreProvider, budgetId: Int) { + let budgetDataStore = dataStoreProvider.budgetsDataStore() + budgetDataStore.getBudget(budgetId) + self.budgetDataStore = budgetDataStore + } +} + +struct UserLineItem: View { + var body: some View { + stateContent + } + + 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)) + } + } + + @ObservedObject var userDataStore: UserDataStore + init(_ dataStoreProvider: DataStoreProvider, userId: Int) { + let userDataStore = dataStoreProvider.userDataStore() + userDataStore.getUser(userId) + self.userDataStore = userDataStore + } +} + +struct TransactionDetailsView_Previews: PreviewProvider { + static var previews: some View { + TransactionDetailsView(MockDataStoreProvider(), transactionId: 2) + } +} diff --git a/BudgetApp/Transaction/TransactionListView.swift b/BudgetApp/Transaction/TransactionListView.swift index a25f53e..1ab46ae 100644 --- a/BudgetApp/Transaction/TransactionListView.swift +++ b/BudgetApp/Transaction/TransactionListView.swift @@ -47,12 +47,11 @@ struct TransactionListView: View { struct TransactionListItemView: View { var transaction: Transaction let dataStoreProvider: DataStoreProvider - let numberFormatter: NumberFormatter var body: some View { NavigationLink( - destination: TransactionDetailsView(self.dataStoreProvider, transaction: transaction) - .navigationBarTitle(transaction.title) + destination: TransactionDetailsView(self.dataStoreProvider, transactionId: transaction.id!) + .navigationBarTitle("details", displayMode: .inline) ) { HStack { VStack(alignment: .leading) { @@ -67,7 +66,7 @@ struct TransactionListItemView: View { } Spacer() VStack(alignment: .trailing) { - Text(verbatim: self.numberFormatter.string(from: NSNumber(value: Double(transaction.amount) / 100.0)) ?? "") + Text(verbatim: transaction.amount.toCurrencyString() ?? "") .foregroundColor(transaction.expense ? .red : .green) .multilineTextAlignment(.trailing) } @@ -79,9 +78,5 @@ struct TransactionListItemView: View { init (_ dataStoreProvider: DataStoreProvider, transaction: Transaction) { self.dataStoreProvider = dataStoreProvider self.transaction = transaction - let formatter = NumberFormatter() - formatter.locale = Locale.current - formatter.numberStyle = .currency - self.numberFormatter = formatter } } diff --git a/BudgetApp/Transaction/TransactionRepository.swift b/BudgetApp/Transaction/TransactionRepository.swift index 7e5aafd..7ca41ad 100644 --- a/BudgetApp/Transaction/TransactionRepository.swift +++ b/BudgetApp/Transaction/TransactionRepository.swift @@ -37,46 +37,28 @@ class NetworkTransactionRepository: TransactionRepository { #if DEBUG class MockTransactionRepository: TransactionRepository { - func getTransactions(categoryIds: [Int]? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Transaction], NetworkError> { - return Result.Publisher([Transaction( - id: 2, - title: "Test Transaction", - description: "A mock transaction used for testing", - date: Date(), - amount: 10000, - categoryId: 3, - expense: true, - createdBy: 0, - budgetId: 1 - )]).eraseToAnyPublisher() + static let transaction: Transaction = Transaction( + id: 2, + title: "Test Transaction", + description: "A mock transaction used for testing", + date: Date(), + amount: 10000, + categoryId: MockCategoryRepository.category.id!, + expense: true, + createdBy: MockUserRepository.user.id!, + budgetId: MockBudgetRepository.budget.id! + ) + + func getTransactions(categoryIds: [Int]?, from: Date?, count: Int?, page: Int?) -> AnyPublisher<[Transaction], NetworkError> { + return Result.Publisher([MockTransactionRepository.transaction]).eraseToAnyPublisher() } func getTransaction(_ transactionId: Int) -> AnyPublisher { - return Result.Publisher(Transaction( - id: 2, - title: "Test Transaction", - description: "A mock transaction used for testing", - date: Date(), - amount: 10000, - categoryId: 3, - expense: true, - createdBy: 0, - budgetId: 1 - )).eraseToAnyPublisher() + return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() } func createTransaction(_ transaction: Transaction) -> AnyPublisher { - return Result.Publisher(Transaction( - id: 2, - title: "Test Transaction", - description: "A mock transaction used for testing", - date: Date(), - amount: 10000, - categoryId: 3, - expense: true, - createdBy: 0, - budgetId: 1 - )).eraseToAnyPublisher() + return Result.Publisher(MockTransactionRepository.transaction).eraseToAnyPublisher() } } #endif diff --git a/BudgetApp/User/AuthenticationDataStore.swift b/BudgetApp/User/AuthenticationDataStore.swift new file mode 100644 index 0000000..e6da196 --- /dev/null +++ b/BudgetApp/User/AuthenticationDataStore.swift @@ -0,0 +1,80 @@ +import Foundation +import Combine + +class AuthenticationDataStore: ObservableObject { + + var currentUser: Result = .failure(.unauthenticated) { + didSet { + self.objectWillChange.send() + } + } + + func login(username: String, password: String) { + + // Changes the status and notifies any observers of the change + self.currentUser = .failure(.authenticating) + + // Perform the login + _ = self.userRepository.login(username: username, password: password) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (status) in + switch status { + case .finished: + return + // Do nothing it means the network request just ended + case .failure(let error): + switch error { + case .jsonParsingFailed(let jsonError): + print(jsonError.localizedDescription) + default: + print(error.localizedDescription) + } + // Poulate your status with failed authenticating + self.currentUser = .failure(.failedAuthentication) + } + }) { (user) in + self.currentUser = .success(user) + } + } + + func register(username: String, email: String, password: String, confirmPassword: String) { + self.currentUser = .failure(.authenticating) + + // TODO: Validate other fields as well + if !password.elementsEqual(confirmPassword) { + self.currentUser = .failure(.passwordMismatch) + return + } + + _ = self.userRepository.register(username: username, email: email, password: password) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (status) in + switch status { + case .finished: + return + // Do nothing it means the network request just ended + case .failure( _): + // Poulate your status with failed authenticating + self.currentUser = .failure(.failedAuthentication) + } + }) { (user) in + self.currentUser = .success(user) + } + } + + init(_ userRepository: UserRepository) { + self.userRepository = userRepository + } + + // Needed since the default implementation is currently broken + let objectWillChange = ObservableObjectPublisher() + private let userRepository: UserRepository +} + +enum UserStatus: Error, Equatable { + case unauthenticated + case authenticating + case failedAuthentication + case authenticated + case passwordMismatch // Passwords don't match +} diff --git a/BudgetApp/User/UserDataStore.swift b/BudgetApp/User/UserDataStore.swift index a147237..17bf1b7 100644 --- a/BudgetApp/User/UserDataStore.swift +++ b/BudgetApp/User/UserDataStore.swift @@ -1,65 +1,39 @@ +// +// UserDataStore.swift +// BudgetApp +// +// Created by Billy Brawner on 10/12/19. +// Copyright © 2019 William Brawner. All rights reserved. +// + import Foundation import Combine class UserDataStore: ObservableObject { - var currentUser: Result = .failure(.unauthenticated) { + var user: Result = .failure(.loading) { didSet { self.objectWillChange.send() } } - - func login(username: String, password: String) { + + func getUser(_ id: Int) { + self.user = .failure(.loading) - // Changes the status and notifies any observers of the change - self.currentUser = .failure(.authenticating) - - // Perform the login - _ = self.userRepository.login(username: username, password: password) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (status) in - switch status { - case .finished: - return - // Do nothing it means the network request just ended - case .failure(let error): - switch error { - case .jsonParsingFailed(let jsonError): - print(jsonError.localizedDescription) - default: - print(error.localizedDescription) - } - // Poulate your status with failed authenticating - self.currentUser = .failure(.failedAuthentication) - } - }) { (user) in - self.currentUser = .success(user) - } - } - - func register(username: String, email: String, password: String, confirmPassword: String) { - self.currentUser = .failure(.authenticating) - - // TODO: Validate other fields as well - if !password.elementsEqual(confirmPassword) { - self.currentUser = .failure(.passwordMismatch) - return - } - - _ = self.userRepository.register(username: username, email: email, password: password) - .receive(on: DispatchQueue.main) - .sink(receiveCompletion: { (status) in - switch status { - case .finished: - return - // Do nothing it means the network request just ended - case .failure( _): - // Poulate your status with failed authenticating - self.currentUser = .failure(.failedAuthentication) - } - }) { (user) in - self.currentUser = .success(user) - } + _ = userRepository.getUser(id) + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { (status) in + switch status { + case .finished: + return + case .failure(let error): + self.user = .failure(error) + return + } + }, receiveValue: { (user) in + self.user = .success(user) + }) + } init(_ userRepository: UserRepository) { @@ -70,11 +44,3 @@ class UserDataStore: ObservableObject { let objectWillChange = ObservableObjectPublisher() private let userRepository: UserRepository } - -enum UserStatus: Error, Equatable { - case unauthenticated - case authenticating - case failedAuthentication - case authenticated - case passwordMismatch // Passwords don't match -} diff --git a/BudgetApp/User/UserRepository.swift b/BudgetApp/User/UserRepository.swift index 04d18d5..e4960a5 100644 --- a/BudgetApp/User/UserRepository.swift +++ b/BudgetApp/User/UserRepository.swift @@ -10,8 +10,8 @@ import Foundation import Combine protocol UserRepository { - func getUser(id: Int) -> AnyPublisher - func searchUsers(withUsername: String) -> AnyPublisher<[User], NetworkError> + func getUser(_ id: Int) -> AnyPublisher + func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> func login(username: String, password: String) -> AnyPublisher func register(username: String, email: String, password: String) -> AnyPublisher } @@ -23,11 +23,11 @@ class NetworkUserRepository: UserRepository { self.apiService = apiService } - func getUser(id: Int) -> AnyPublisher { + func getUser(_ id: Int) -> AnyPublisher { return apiService.getUser(id: id) } - func searchUsers(withUsername: String) -> AnyPublisher<[User], NetworkError> { + func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> { return apiService.searchUsers(query: withUsername) } @@ -43,23 +43,25 @@ class NetworkUserRepository: UserRepository { #if DEBUG class MockUserRepository: UserRepository { - func getUser(id: Int) -> AnyPublisher { - return Result.Publisher(.failure(NetworkError.unknown)) + static let user = User(id: 0, username: "root", email: "root@localhost", avatar: nil) + + func getUser(_ id: Int) -> AnyPublisher { + return Result.Publisher(MockUserRepository.user) .eraseToAnyPublisher() } - func searchUsers(withUsername: String) -> AnyPublisher<[User], NetworkError> { - return Result<[User], NetworkError>.Publisher(.failure(NetworkError.unknown)) + func searchUsers(_ withUsername: String) -> AnyPublisher<[User], NetworkError> { + return Result<[User], NetworkError>.Publisher([MockUserRepository.user]) .eraseToAnyPublisher() } func login(username: String, password: String) -> AnyPublisher { - return Result.Publisher(.failure(NetworkError.unknown)) + return Result.Publisher(MockUserRepository.user) .eraseToAnyPublisher() } func register(username: String, email: String, password: String) -> AnyPublisher { - return Result.Publisher(.failure(NetworkError.unknown)) + return Result.Publisher(MockUserRepository.user) .eraseToAnyPublisher() } } diff --git a/BudgetApp/en.lproj/Localizable.strings b/BudgetApp/en.lproj/Localizable.strings index b5a40e5..bcf0a0d 100644 --- a/BudgetApp/en.lproj/Localizable.strings +++ b/BudgetApp/en.lproj/Localizable.strings @@ -9,8 +9,10 @@ // MARK: Generic "add" = "Add"; "filter" = "Filter"; +"edit" = "Edit"; "save" = "Save"; "cancel" = "Cancel"; +"details" = "Details"; "loading_default" = "Loading..."; "prompt_name" = "Name"; "prompt_description" = "Description"; @@ -21,6 +23,7 @@ "prompt_type" = "Type"; "type_income" = "Income"; "type_expense" = "Expense"; +"retry" = "Retry"; // MARK: Login "info_login" = "Login to start managing your budget"; @@ -38,8 +41,15 @@ // MARK: Transactions "transactions" = "Transactions"; "add_transaction" = "Add Transaction"; +"notes" = "Notes"; +"registered_by" = "Registered by"; + +// MARK: Categories +"category" = "Category"; +"categories" = "Categories"; // MARK: Budgets +"budget" = "Budget"; "budgets" = "Budgets"; // MARK: Profile diff --git a/BudgetApp/es-419.lproj/Localizable.strings b/BudgetApp/es-419.lproj/Localizable.strings index 6d17e72..bf12769 100644 --- a/BudgetApp/es-419.lproj/Localizable.strings +++ b/BudgetApp/es-419.lproj/Localizable.strings @@ -9,8 +9,10 @@ // MARK: Generic "add" = "Agregar"; "filter" = "Filtrar"; +"edit" = "Editar"; "save" = "Guardar"; "cancel" = "Cancelar"; +"details" = "Detalles"; "loading_default" = "Cargando..."; "prompt_name" = "Nombre"; "prompt_description" = "Descripción"; @@ -21,6 +23,7 @@ "prompt_type" = "Tipo"; "type_income" = "Ingreso"; "type_expense" = "Gasto"; +"retry" = "Volver a intentar"; // MARK: Login "info_login" = "Inicia sesión para empezar a manejar su presupuseto"; @@ -38,8 +41,15 @@ // MARK: Transactions "transactions" = "Transacciones"; "add_transaction" = "Agregar Transacción"; +"notes" = "Notas"; +"registered_by" = "Registrado por"; + +// MARK: Categories +"category" = "Categoría"; +"categories" = "Categorías"; // MARK: Budgets +"budget" = "Presupuesto"; "budgets" = "Presupuestos"; // MARK: Profile