Implement category balances
This commit is contained in:
parent
8f3d33fc1d
commit
5522e3b573
10 changed files with 129 additions and 32 deletions
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)) ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue