Implement category balances

This commit is contained in:
William Brawner 2021-09-20 19:59:13 -06:00
parent 8f3d33fc1d
commit 5522e3b573
10 changed files with 129 additions and 32 deletions

View file

@ -12,7 +12,6 @@ import Combine
protocol BudgetRepository { protocol BudgetRepository {
func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError> func getBudgets(count: Int?, page: Int?) -> AnyPublisher<[Budget], NetworkError>
func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError> func getBudget(_ id: String) -> AnyPublisher<Budget, NetworkError>
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError>
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError>
func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> func updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError>
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError> func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError>
@ -48,10 +47,6 @@ class NetworkBudgetRepository: BudgetRepository {
}.eraseToAnyPublisher() }.eraseToAnyPublisher()
} }
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
return apiService.getBudgetBalance(id)
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> { func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return apiService.newBudget(budget) return apiService.newBudget(budget)
} }
@ -83,10 +78,6 @@ class MockBudgetRepository: BudgetRepository {
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher()
} }
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
return Result.Publisher(10000).eraseToAnyPublisher()
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> { func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher() return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher()
} }

View file

@ -51,6 +51,18 @@ struct CategoryListItemView: View {
var category: Category var category: Category
let budget: Budget let budget: Budget
let dataStoreProvider: DataStoreProvider let dataStoreProvider: DataStoreProvider
let sumId: String
@ObservedObject var transactionDataStore: TransactionDataStore
var progressTintColor: Color {
get {
if category.expense {
return Color.red
} else {
return Color.green
}
}
}
var body: some View { var body: some View {
NavigationLink( NavigationLink(
@ -58,21 +70,56 @@ struct CategoryListItemView: View {
.navigationBarTitle(category.title) .navigationBarTitle(category.title)
) { ) {
VStack(alignment: .leading) { VStack(alignment: .leading) {
HStack {
Text(verbatim: category.title) Text(verbatim: category.title)
Spacer()
remaining
}
if category.description?.isEmpty == false { if category.description?.isEmpty == false {
Text(verbatim: category.description!) Text(verbatim: category.description!)
.font(.subheadline) .font(.subheadline)
.foregroundColor(.secondary) .foregroundColor(.secondary)
.lineLimit(1) .lineLimit(1)
} }
progressView
} }
} }
} }
var progressView: ProgressView {
var balance: Float = 0.0
if case .success(let sum) = transactionDataStore.sums[sumId] {
balance = Float(abs(sum.balance))
}
return ProgressView(value: balance, maxValue: Float(category.amount), progressTintColor: progressTintColor, progressBarHeight: 4.0)
}
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 amount < 0 {
remaining = abs(amount).toCurrencyString() + " over budget"
if category.expense {
color = Color.red
} else {
color = Color.green
}
} else {
remaining = amount.toCurrencyString() + " remaining"
}
}
return Text(verbatim: remaining).foregroundColor(color)
}
init (_ dataStoreProvider: DataStoreProvider, budget: Budget, category: Category) { init (_ dataStoreProvider: DataStoreProvider, budget: Budget, category: Category) {
self.dataStoreProvider = dataStoreProvider self.dataStoreProvider = dataStoreProvider
self.budget = budget self.budget = budget
self.category = category self.category = category
let transactionDataStore = dataStoreProvider.transactionDataStore()
self.transactionDataStore = transactionDataStore
self.sumId = transactionDataStore.sum(categoryId: category.id)
} }
} }

View file

@ -9,12 +9,12 @@
import Foundation import Foundation
extension Int { extension Int {
func toCurrencyString() -> String? { func toCurrencyString() -> String {
let currencyFormatter = NumberFormatter() let currencyFormatter = NumberFormatter()
currencyFormatter.locale = Locale.current currencyFormatter.locale = Locale.current
currencyFormatter.numberStyle = .currency currencyFormatter.numberStyle = .currency
let doubleSelf = Double(self) / 100.0 let doubleSelf = Double(self) / 100.0
return currencyFormatter.string(from: NSNumber(value: doubleSelf)) return currencyFormatter.string(from: NSNumber(value: doubleSelf)) ?? ""
} }
} }

View file

@ -33,10 +33,6 @@ class BudgetAppApiService {
return requestHelper.get("/api/budgets/\(id)") return requestHelper.get("/api/budgets/\(id)")
} }
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
return requestHelper.get("/api/budgets/\(id)/balance")
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> { func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return requestHelper.post("/api/budgets", data: budget, type: Budget.self) return requestHelper.post("/api/budgets", data: budget, type: Budget.self)
} }
@ -97,6 +93,23 @@ class BudgetAppApiService {
return requestHelper.delete("/api/transactions/\(id)") return requestHelper.delete("/api/transactions/\(id)")
} }
func sumTransactions(budgetId: String? = nil, categoryId: String? = nil, from: Date? = nil, to: Date? = nil) -> AnyPublisher<BalanceResponse, NetworkError> {
var queries = [String: Array<String>]()
if let budgetId = budgetId {
queries["budgetId"] = [budgetId]
}
if let categoryId = categoryId {
queries["categoryId"] = [categoryId]
}
if let from = from {
queries["from"] = [from.toISO8601String()]
}
if let to = to {
queries["to"] = [to.toISO8601String()]
}
return requestHelper.get("/api/transactions/sum", queries: queries)
}
// MARK: Categories // MARK: Categories
func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Category], NetworkError> { func getCategories(budgetId: String? = nil, expense: Bool? = nil, archived: Bool? = nil, count: Int? = nil, page: Int? = nil) -> AnyPublisher<[Category], NetworkError> {

View file

@ -21,6 +21,10 @@ struct Transaction: Identifiable, Hashable, Codable {
let budgetId: String let budgetId: String
} }
struct BalanceResponse: Codable {
let balance: Int
}
enum TransactionType: Int, CaseIterable, Identifiable, Hashable { enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
case expense case expense
case income case income

View file

@ -11,6 +11,7 @@ import Combine
class TransactionDataStore: ObservableObject { class TransactionDataStore: ObservableObject {
private var currentRequest: AnyCancellable? = nil private var currentRequest: AnyCancellable? = nil
private var sumRequests: [String:AnyCancellable] = [:]
var transactions: Result<[Transaction], NetworkError> = .failure(.loading) { var transactions: Result<[Transaction], NetworkError> = .failure(.loading) {
didSet { didSet {
self.objectWillChange.send() self.objectWillChange.send()
@ -23,6 +24,12 @@ class TransactionDataStore: ObservableObject {
} }
} }
var sums: [String:Result<BalanceResponse, NetworkError>] = [:] {
didSet {
self.objectWillChange.send()
}
}
func getTransactions(_ budget: Budget, category: Category? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) { func getTransactions(_ budget: Budget, category: Category? = nil, from: Date? = nil, count: Int? = nil, page: Int? = nil) {
self.transactions = .failure(.loading) self.transactions = .failure(.loading)
@ -113,6 +120,25 @@ class TransactionDataStore: ObservableObject {
}) })
} }
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 reset() { func reset() {
self.transaction = .failure(.unknown) self.transaction = .failure(.unknown)
self.transactions = .failure(.loading) self.transactions = .failure(.loading)

View file

@ -27,7 +27,7 @@ struct TransactionDetailsView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(transaction.title) Text(transaction.title)
.font(.title) .font(.title)
Text(transaction.amount.toCurrencyString() ?? "") Text(transaction.amount.toCurrencyString())
.font(.headline) .font(.headline)
.foregroundColor(transaction.expense ? .red : .green) .foregroundColor(transaction.expense ? .red : .green)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)

View file

@ -65,7 +65,7 @@ struct TransactionListItemView: View {
} }
Spacer() Spacer()
VStack(alignment: .trailing) { VStack(alignment: .trailing) {
Text(verbatim: transaction.amount.toCurrencyString() ?? "") Text(verbatim: transaction.amount.toCurrencyString())
.foregroundColor(transaction.expense ? .red : .green) .foregroundColor(transaction.expense ? .red : .green)
.multilineTextAlignment(.trailing) .multilineTextAlignment(.trailing)
} }

View file

@ -15,6 +15,7 @@ protocol TransactionRepository {
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError> func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError>
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError>
} }
class NetworkTransactionRepository: TransactionRepository { class NetworkTransactionRepository: TransactionRepository {
@ -43,6 +44,10 @@ class NetworkTransactionRepository: TransactionRepository {
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> { func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> {
return apiService.deleteTransaction(transactionId) return apiService.deleteTransaction(transactionId)
} }
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError> {
return apiService.sumTransactions(budgetId: budgetId, categoryId: categoryId, from: from, to: to)
}
} }
#if DEBUG #if DEBUG
@ -78,5 +83,9 @@ class MockTransactionRepository: TransactionRepository {
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> { func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> {
return Result.Publisher(.success(Empty())).eraseToAnyPublisher() return Result.Publisher(.success(Empty())).eraseToAnyPublisher()
} }
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError> {
return Result.Publisher(.success(BalanceResponse(balance: 1000))).eraseToAnyPublisher()
}
} }
#endif #endif

View file

@ -9,11 +9,11 @@
import SwiftUI import SwiftUI
struct ProgressView: View { struct ProgressView: View {
@Binding var value: CGFloat var value: Float
var maxValue: CGFloat = 100.0 var maxValue: Float = 100.0
var progressTintColor: Color = .blue var progressTintColor: Color = .blue
var progressBarHeight: CGFloat = 20 var progressBarHeight: Float = 20
var progressBarCornerRadius: CGFloat = 4.0 var progressBarCornerRadius: Float = 4.0
var body: some View { var body: some View {
GeometryReader { geometry in GeometryReader { geometry in
@ -21,28 +21,35 @@ struct ProgressView: View {
Rectangle() Rectangle()
.opacity(0.1) .opacity(0.1)
Rectangle() Rectangle()
.frame( .frame(width: getProgressBarWidth(geometry: geometry))
minWidth: 0,
idealWidth: self.getProgressBarWidth(geometry: geometry),
maxWidth: self.getProgressBarWidth(geometry: geometry)
)
.opacity(0.5) .opacity(0.5)
.background(self.progressTintColor) .background(self.progressTintColor)
.animation(.default) .animation(.default)
}.frame(height: self.progressBarHeight) }.frame(height: CGFloat(self.progressBarHeight))
.cornerRadius(self.progressBarCornerRadius) .cornerRadius(CGFloat(self.progressBarCornerRadius))
} }
} }
private func getProgressBarWidth(geometry: GeometryProxy) -> CGFloat { private func getProgressBarWidth(geometry: GeometryProxy) -> CGFloat {
let frame = geometry.frame(in: .global) let frame = geometry.frame(in: .global)
return frame.size.width * (value / maxValue) return frame.size.width * min(CGFloat(value / maxValue), CGFloat(1))
} }
} }
struct ProgressView_Previews: PreviewProvider { struct ProgressView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
ProgressView(value: .constant(50.0), maxValue: 100.0, progressTintColor: .red) VStack {
Text("0%")
ProgressView(value: 1.0, maxValue: 0.0, progressTintColor: .red)
Text("25%")
ProgressView(value: 1.0, maxValue: 4.0, progressTintColor: .green)
Text("50%")
ProgressView(value: 1.0, maxValue: 2.0, progressTintColor: .blue)
Text("66%")
ProgressView(value: 2.0, maxValue: 3.0, progressTintColor: .orange)
Text("150%")
ProgressView(value: 150.0, maxValue: 100.0, progressTintColor: .purple)
}.padding(50)
} }
} }