diff --git a/Twigs.xcodeproj/project.pbxproj b/Twigs.xcodeproj/project.pbxproj index 27e78f1..d95046d 100644 --- a/Twigs.xcodeproj/project.pbxproj +++ b/Twigs.xcodeproj/project.pbxproj @@ -49,6 +49,7 @@ 28FE6B0623444A9800D5543E /* TransactionDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */; }; 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */; }; 8043EB84271F26ED00498E73 /* CategoryDetailsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */; }; + 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -100,8 +101,6 @@ 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 = ""; }; - 28AC9522233C384C00BFB70A /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/LaunchScreen.strings"; sourceTree = ""; }; - 28AC9523233C384C00BFB70A /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.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 = ""; }; @@ -120,6 +119,8 @@ 28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = ""; }; 543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = ""; }; 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,11 +171,12 @@ 2841022A2342D8CB00EAFA29 /* Category */ = { isa = PBXGroup; children = ( - 284102312342E12F00EAFA29 /* CategoryListView.swift */, 28FE6AF723441E1D00D5543E /* Category.swift */, 28FE6AF923441E3700D5543E /* CategoryDataStore.swift */, - 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */, 8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */, + 284102312342E12F00EAFA29 /* CategoryListView.swift */, + 28FE6AFB23441E4500D5543E /* CategoryRepository.swift */, + 809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */, ); path = Category; sourceTree = ""; @@ -212,27 +214,27 @@ isa = PBXGroup; children = ( 282126A4235BCB7500072D52 /* Twigs.entitlements */, - 2821269F2359299D00072D52 /* Profile */, - 2841022A2342D8CB00EAFA29 /* Category */, - 284102292342D8BB00EAFA29 /* Budget */, - 2857EAEB233DA2F90026BC83 /* Views */, - 28AC952A233C433C00BFB70A /* User */, - 28AC9527233C430A00BFB70A /* Network */, - 28AC9526233C42F800BFB70A /* Transaction */, - 28AC94ED233C373900BFB70A /* AppDelegate.swift */, - 28AC94EF233C373900BFB70A /* SceneDelegate.swift */, - 28AC94F1233C373900BFB70A /* LoginView.swift */, - 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */, - 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */, - 284102242341998300EAFA29 /* ContentView.swift */, - 28AC9521233C381C00BFB70A /* Localizable.strings */, - 28AC94F3233C373A00BFB70A /* Assets.xcassets */, - 28AC94F8233C373A00BFB70A /* LaunchScreen.storyboard */, 28AC94FB233C373A00BFB70A /* Info.plist */, - 28AC94F5233C373A00BFB70A /* Preview Content */, + 28AC94ED233C373900BFB70A /* AppDelegate.swift */, + 284102242341998300EAFA29 /* ContentView.swift */, 28FE6AFD234428BF00D5543E /* DataStoreProvider.swift */, - 2888234623512DBF003D3847 /* Observable.swift */, 28CE8B9423525F990072BC4C /* Extensions.swift */, + 28AC94F1233C373900BFB70A /* LoginView.swift */, + 2888234623512DBF003D3847 /* Observable.swift */, + 28B9E50D2346BCB2007C3909 /* RegistrationView.swift */, + 28AC94EF233C373900BFB70A /* SceneDelegate.swift */, + 2841022623419A2B00EAFA29 /* TabbedBudgetView.swift */, + 28AC94F3233C373A00BFB70A /* Assets.xcassets */, + 284102292342D8BB00EAFA29 /* Budget */, + 2841022A2342D8CB00EAFA29 /* Category */, + 28AC94F8233C373A00BFB70A /* LaunchScreen.storyboard */, + 28AC9521233C381C00BFB70A /* Localizable.strings */, + 28AC9527233C430A00BFB70A /* Network */, + 28AC94F5233C373A00BFB70A /* Preview Content */, + 2821269F2359299D00072D52 /* Profile */, + 28AC9526233C42F800BFB70A /* Transaction */, + 28AC952A233C433C00BFB70A /* User */, + 2857EAEB233DA2F90026BC83 /* Views */, ); path = Twigs; sourceTree = ""; @@ -384,7 +386,7 @@ knownRegions = ( en, Base, - "es-419", + es, ); mainGroup = 28AC94E1233C373900BFB70A; productRefGroup = 28AC94EB233C373900BFB70A /* Products */; @@ -460,6 +462,7 @@ 28FE6AF823441E1D00D5543E /* Category.swift in Sources */, 282126A1235929B800072D52 /* ProfileView.swift in Sources */, 28AC9529233C433400BFB70A /* TransactionRepository.swift in Sources */, + 809B942327221EC800B1DAE2 /* CategoryFormSheet.swift in Sources */, 28FE6AF62342E4CC00D5543E /* BudgetRepository.swift in Sources */, 28FE6B022344331B00D5543E /* TransactionDataStore.swift in Sources */, 543ECE42233E82A40018A9D9 /* AuthenticationDataStore.swift in Sources */, @@ -506,7 +509,6 @@ isa = PBXVariantGroup; children = ( 28AC94F9233C373A00BFB70A /* Base */, - 28AC9522233C384C00BFB70A /* es-419 */, ); name = LaunchScreen.storyboard; sourceTree = ""; @@ -515,7 +517,7 @@ isa = PBXVariantGroup; children = ( 28AC9520233C381C00BFB70A /* en */, - 28AC9523233C384C00BFB70A /* es-419 */, + 809B94242722597800B1DAE2 /* es */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Twigs/Category/CategoryDataStore.swift b/Twigs/Category/CategoryDataStore.swift index 9eed036..4631cec 100644 --- a/Twigs/Category/CategoryDataStore.swift +++ b/Twigs/Category/CategoryDataStore.swift @@ -12,12 +12,7 @@ import Combine class CategoryDataStore: ObservableObject { private var currentRequest: AnyCancellable? = nil @Published var categories: [String:Result<[Category], NetworkError>] = ["":.failure(.loading)] - - var category: Result = .failure(.loading) { - didSet { - self.objectWillChange.send() - } - } + @Published var category: Result = .failure(.unknown) 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))" @@ -59,8 +54,52 @@ class CategoryDataStore: ObservableObject { self.category = .success(category) }) } - - let objectWillChange = ObservableObjectPublisher() + + 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) + } + 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) + }) + } + private let categoryRepository: CategoryRepository init(_ categoryRepository: CategoryRepository) { self.categoryRepository = categoryRepository diff --git a/Twigs/Category/CategoryDetailsView.swift b/Twigs/Category/CategoryDetailsView.swift index ab582a5..820e441 100644 --- a/Twigs/Category/CategoryDetailsView.swift +++ b/Twigs/Category/CategoryDetailsView.swift @@ -13,6 +13,7 @@ struct CategoryDetailsView: View { let budget: Budget let category: Category @State var sumRequest: String = "" + @State var editingCategory: Bool = false var spent: Int { get { if case let .success(res) = transactionDataStore.sums[sumRequest] { @@ -53,6 +54,17 @@ struct CategoryDetailsView: View { sumRequest = transactionDataStore.sum(budgetId: nil, categoryId: category.id, from: nil, to: nil) } } + .navigationBarItems(trailing: Button(action: { + self.editingCategory = true + }) { + Text("edit") + } + ) + .sheet(isPresented: self.$editingCategory, onDismiss: { + self.editingCategory = false + }, content: { + CategoryFormSheet(showSheet: self.$editingCategory, category: self.category, budgetId: self.category.budgetId) + }) } init (_ category: Category, budget: Budget) { diff --git a/Twigs/Category/CategoryFormSheet.swift b/Twigs/Category/CategoryFormSheet.swift new file mode 100644 index 0000000..173f099 --- /dev/null +++ b/Twigs/Category/CategoryFormSheet.swift @@ -0,0 +1,108 @@ +// +// EditCategoryView.swift +// Twigs +// +// Created by William Brawner on 10/21/21. +// Copyright © 2021 William Brawner. All rights reserved. +// + +import SwiftUI + +struct CategoryFormSheet: View { + @EnvironmentObject var categoryDataStore: CategoryDataStore + @Binding var showSheet: Bool + @State var title: String + @State var description: String + @State var amount: String + @State var type: TransactionType + @State var archived: Bool + let categoryId: String + 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 { + TextField("prompt_name", text: self.$title) + TextField("prompt_description", text: self.$description) + TextField("prompt_amount", text: self.$amount) + .keyboardType(.decimalPad) + Picker("prompt_type", selection: self.$type) { + ForEach(TransactionType.allCases) { type in + Text(type.localizedKey) + } + } + Toggle("prompt_archived", isOn: self.$archived) + if categoryId != "" { + Button(action: { + self.showingAlert = true + }) { + Text("delete") + .foregroundColor(.red) + } + .alert(isPresented:$showingAlert) { + Alert(title: Text("confirm_delete"), message: Text("cannot_undo"), primaryButton: .destructive(Text("delete"), action: { + self.categoryDataStore.delete(categoryId) + }), secondaryButton: .cancel()) + } + } else { + EmptyView() + } + }) + } + } + + @ViewBuilder + var body: some View { + NavigationView { + stateContent + .navigationBarItems( + leading: Button("cancel") { + self.showSheet = false + }, + 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 + )) + }) + } + } + + init(showSheet: Binding, category: Category?, budgetId: String) { + let initialCategory = category ?? 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 ?? "") + self._amount = State(initialValue: initialCategory.amount.toDecimalString()) + let type: TransactionType + if initialCategory.expense == false { + type = .income + } else { + type = .expense + } + self._type = State(initialValue: type) + self._archived = State(initialValue: initialCategory.archived) + self.categoryId = initialCategory.id + self.budgetId = budgetId + } +} + +struct CategoryFormSheet_Previews: PreviewProvider { + static var previews: some View { + CategoryFormSheet(showSheet: .constant(true), category: nil, budgetId: "") + .environmentObject(CategoryDataStore(MockCategoryRepository())) + } +} diff --git a/Twigs/Category/CategoryRepository.swift b/Twigs/Category/CategoryRepository.swift index 56b7ca1..d6bf8aa 100644 --- a/Twigs/Category/CategoryRepository.swift +++ b/Twigs/Category/CategoryRepository.swift @@ -12,6 +12,9 @@ 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 } class NetworkCategoryRepository: CategoryRepository { @@ -47,6 +50,27 @@ class NetworkCategoryRepository: CategoryRepository { return category }.eraseToAnyPublisher() } + + func createCategory(_ category: Category) -> AnyPublisher { + return apiService.newCategory(category).map { + self.cacheService?.addCategory($0) + return $0 + }.eraseToAnyPublisher() + } + + func updateCategory(_ category: Category) -> AnyPublisher { + return apiService.updateCategory(category).map { + self.cacheService?.updateCategory($0) + return $0 + }.eraseToAnyPublisher() + } + + func deleteCategory(_ id: String) -> AnyPublisher { + return apiService.deleteCategory(id).map { + self.cacheService?.removeCategory(id) + return $0 + }.eraseToAnyPublisher() + } } #if DEBUG @@ -69,6 +93,18 @@ class MockCategoryRepository: CategoryRepository { func getCategory(_ categoryId: String) -> AnyPublisher { return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() } + + func createCategory(_ category: Category) -> AnyPublisher { + return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() + } + + func updateCategory(_ category: Category) -> AnyPublisher { + return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher() + } + + func deleteCategory(_ id: String) -> AnyPublisher { + return Result.Publisher(.success(Empty())).eraseToAnyPublisher() + } } #endif diff --git a/Twigs/Extensions.swift b/Twigs/Extensions.swift index 47c1cc1..a183833 100644 --- a/Twigs/Extensions.swift +++ b/Twigs/Extensions.swift @@ -9,6 +9,10 @@ import Foundation extension Int { + func toDecimalString() -> String { + return String(format: "%.2f", Double(self) / 100.0) + } + func toCurrencyString() -> String { let currencyFormatter = NumberFormatter() currencyFormatter.locale = Locale.current diff --git a/Twigs/Network/TwigsApiService.swift b/Twigs/Network/TwigsApiService.swift index 6d6907d..22fdaf2 100644 --- a/Twigs/Network/TwigsApiService.swift +++ b/Twigs/Network/TwigsApiService.swift @@ -315,7 +315,7 @@ class RequestHelper { default: throw NetworkError.unknown } } - print(String(data: data, encoding: String.Encoding.utf8)) +// print(String(data: data, encoding: String.Encoding.utf8)) return data } .decode(type: ResultType.self, decoder: self.decoder) diff --git a/Twigs/Network/TwigsInMemoryCacheService.swift b/Twigs/Network/TwigsInMemoryCacheService.swift index d75e8b9..95f69eb 100644 --- a/Twigs/Network/TwigsInMemoryCacheService.swift +++ b/Twigs/Network/TwigsInMemoryCacheService.swift @@ -97,7 +97,19 @@ class TwigsInMemoryCacheService { func addCategory(_ category: Category) { self.categories.insert(category) } + + func updateCategory(_ category: Category) { + if let index = self.categories.firstIndex(where: { $0.id == category.id }) { + self.categories.remove(at: index) + } + self.categories.insert(category) + } + func removeCategory(_ id: String) { + if let index = self.categories.firstIndex(where: { $0.id == id }) { + self.categories.remove(at: index) + } + } // MARK: Users func getUser(id: String) -> AnyPublisher? { diff --git a/Twigs/en.lproj/Localizable.strings b/Twigs/en.lproj/Localizable.strings index 1cfbff3..16e898c 100644 --- a/Twigs/en.lproj/Localizable.strings +++ b/Twigs/en.lproj/Localizable.strings @@ -22,6 +22,7 @@ "prompt_category" = "Category"; "prompt_budget" = "Budget"; "prompt_type" = "Type"; +"prompt_archived" = "Archived"; "type_income" = "Income"; "type_expense" = "Expense"; "retry" = "Retry"; diff --git a/Twigs/es-419.lproj/LaunchScreen.strings b/Twigs/es-419.lproj/LaunchScreen.strings deleted file mode 100644 index 8b13789..0000000 --- a/Twigs/es-419.lproj/LaunchScreen.strings +++ /dev/null @@ -1 +0,0 @@ - diff --git a/Twigs/es-419.lproj/Localizable.strings b/Twigs/es.lproj/Localizable.strings similarity index 98% rename from Twigs/es-419.lproj/Localizable.strings rename to Twigs/es.lproj/Localizable.strings index 7069f73..cc024bc 100644 --- a/Twigs/es-419.lproj/Localizable.strings +++ b/Twigs/es.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Budget @@ -22,6 +22,7 @@ "prompt_category" = "Categoría"; "prompt_budget" = "Presupuesto"; "prompt_type" = "Tipo"; +"prompt_archived" = "Archivada"; "type_income" = "Ingreso"; "type_expense" = "Gasto"; "retry" = "Volver a intentar";