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 {
func getBudgets(count: Int?, page: Int?) -> 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 updateBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError>
func deleteBudget(_ id: String) -> AnyPublisher<Empty, NetworkError>
@ -48,10 +47,6 @@ class NetworkBudgetRepository: BudgetRepository {
}.eraseToAnyPublisher()
}
func getBudgetBalance(_ id: String) -> AnyPublisher<Int, NetworkError> {
return apiService.getBudgetBalance(id)
}
func newBudget(_ budget: Budget) -> AnyPublisher<Budget, NetworkError> {
return apiService.newBudget(budget)
}
@ -83,10 +78,6 @@ class MockBudgetRepository: BudgetRepository {
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> {
return Result.Publisher(MockBudgetRepository.budget).eraseToAnyPublisher()
}

View file

@ -51,6 +51,18 @@ struct CategoryListItemView: View {
var category: Category
let budget: Budget
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 {
NavigationLink(
@ -58,21 +70,56 @@ struct CategoryListItemView: View {
.navigationBarTitle(category.title)
) {
VStack(alignment: .leading) {
Text(verbatim: category.title)
HStack {
Text(verbatim: category.title)
Spacer()
remaining
}
if category.description?.isEmpty == false {
Text(verbatim: category.description!)
.font(.subheadline)
.foregroundColor(.secondary)
.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) {
self.dataStoreProvider = dataStoreProvider
self.budget = budget
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
extension Int {
func toCurrencyString() -> String? {
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))
return currencyFormatter.string(from: NSNumber(value: doubleSelf)) ?? ""
}
}

View file

@ -33,10 +33,6 @@ class BudgetAppApiService {
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> {
return requestHelper.post("/api/budgets", data: budget, type: Budget.self)
}
@ -97,6 +93,23 @@ class BudgetAppApiService {
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
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
}
struct BalanceResponse: Codable {
let balance: Int
}
enum TransactionType: Int, CaseIterable, Identifiable, Hashable {
case expense
case income

View file

@ -11,6 +11,7 @@ import Combine
class TransactionDataStore: ObservableObject {
private var currentRequest: AnyCancellable? = nil
private var sumRequests: [String:AnyCancellable] = [:]
var transactions: Result<[Transaction], NetworkError> = .failure(.loading) {
didSet {
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) {
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() {
self.transaction = .failure(.unknown)
self.transactions = .failure(.loading)

View file

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

View file

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

View file

@ -15,6 +15,7 @@ protocol TransactionRepository {
func createTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func updateTransaction(_ transaction: Transaction) -> AnyPublisher<Transaction, NetworkError>
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError>
func sumTransactions(budgetId: String?, categoryId: String?, from: Date?, to: Date?) -> AnyPublisher<BalanceResponse, NetworkError>
}
class NetworkTransactionRepository: TransactionRepository {
@ -43,6 +44,10 @@ class NetworkTransactionRepository: TransactionRepository {
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> {
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
@ -78,5 +83,9 @@ class MockTransactionRepository: TransactionRepository {
func deleteTransaction(_ transactionId: String) -> AnyPublisher<Empty, NetworkError> {
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

View file

@ -9,11 +9,11 @@
import SwiftUI
struct ProgressView: View {
@Binding var value: CGFloat
var maxValue: CGFloat = 100.0
var value: Float
var maxValue: Float = 100.0
var progressTintColor: Color = .blue
var progressBarHeight: CGFloat = 20
var progressBarCornerRadius: CGFloat = 4.0
var progressBarHeight: Float = 20
var progressBarCornerRadius: Float = 4.0
var body: some View {
GeometryReader { geometry in
@ -21,28 +21,35 @@ struct ProgressView: View {
Rectangle()
.opacity(0.1)
Rectangle()
.frame(
minWidth: 0,
idealWidth: self.getProgressBarWidth(geometry: geometry),
maxWidth: self.getProgressBarWidth(geometry: geometry)
)
.frame(width: getProgressBarWidth(geometry: geometry))
.opacity(0.5)
.background(self.progressTintColor)
.animation(.default)
}.frame(height: self.progressBarHeight)
.cornerRadius(self.progressBarCornerRadius)
}.frame(height: CGFloat(self.progressBarHeight))
.cornerRadius(CGFloat(self.progressBarCornerRadius))
}
}
private func getProgressBarWidth(geometry: GeometryProxy) -> CGFloat {
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 {
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)
}
}