Add ability to edit categories and fix Spanish translations

This commit is contained in:
William Brawner 2021-10-21 20:43:01 -06:00
parent 138a645cce
commit 35f1446e9c
11 changed files with 250 additions and 36 deletions

View file

@ -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 = "<group>"; };
28AC9511233C373A00BFB70A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
28AC9520233C381C00BFB70A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = "<group>"; };
28AC9522233C384C00BFB70A /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/LaunchScreen.strings"; sourceTree = "<group>"; };
28AC9523233C384C00BFB70A /* es-419 */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-419"; path = "es-419.lproj/Localizable.strings"; sourceTree = "<group>"; };
28AC9524233C42D100BFB70A /* TwigsApiService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TwigsApiService.swift; sourceTree = "<group>"; };
28AC9528233C433400BFB70A /* TransactionRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionRepository.swift; sourceTree = "<group>"; };
28AC952B233C434800BFB70A /* UserRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserRepository.swift; sourceTree = "<group>"; };
@ -120,6 +119,8 @@
28FE6B0523444A9800D5543E /* TransactionDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TransactionDetailsView.swift; sourceTree = "<group>"; };
543ECE41233E82A40018A9D9 /* AuthenticationDataStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthenticationDataStore.swift; sourceTree = "<group>"; };
8043EB83271F26ED00498E73 /* CategoryDetailsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryDetailsView.swift; sourceTree = "<group>"; };
809B942227221EC800B1DAE2 /* CategoryFormSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFormSheet.swift; sourceTree = "<group>"; };
809B94242722597800B1DAE2 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = "<group>"; };
/* 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 = "<group>";
@ -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 = "<group>";
@ -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 = "<group>";
@ -515,7 +517,7 @@
isa = PBXVariantGroup;
children = (
28AC9520233C381C00BFB70A /* en */,
28AC9523233C384C00BFB70A /* es-419 */,
809B94242722597800B1DAE2 /* es */,
);
name = Localizable.strings;
sourceTree = "<group>";

View file

@ -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<Category, NetworkError> = .failure(.loading) {
didSet {
self.objectWillChange.send()
}
}
@Published var category: Result<Category, NetworkError> = .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<Category, NetworkError>
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

View file

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

View file

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

View file

@ -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<Category, NetworkError>
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError>
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError>
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError>
}
class NetworkCategoryRepository: CategoryRepository {
@ -47,6 +50,27 @@ class NetworkCategoryRepository: CategoryRepository {
return category
}.eraseToAnyPublisher()
}
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return apiService.newCategory(category).map {
self.cacheService?.addCategory($0)
return $0
}.eraseToAnyPublisher()
}
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return apiService.updateCategory(category).map {
self.cacheService?.updateCategory($0)
return $0
}.eraseToAnyPublisher()
}
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
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<Category, NetworkError> {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
}
func createCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
}
func updateCategory(_ category: Category) -> AnyPublisher<Category, NetworkError> {
return Result.Publisher(MockCategoryRepository.category).eraseToAnyPublisher()
}
func deleteCategory(_ id: String) -> AnyPublisher<Empty, NetworkError> {
return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
}
}
#endif

View file

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

View file

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

View file

@ -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<User, NetworkError>? {

View file

@ -22,6 +22,7 @@
"prompt_category" = "Category";
"prompt_budget" = "Budget";
"prompt_type" = "Type";
"prompt_archived" = "Archived";
"type_income" = "Income";
"type_expense" = "Expense";
"retry" = "Retry";

View file

@ -1 +0,0 @@

View file

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