Use Swift Charts for budget overview actual vs expected

This commit is contained in:
William Brawner 2023-02-08 20:17:54 -07:00
parent eb50266745
commit 6af0fda86f
7 changed files with 100 additions and 96 deletions

View file

@ -492,7 +492,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1320; LastSwiftUpdateCheck = 1320;
LastUpgradeCheck = 1340; LastUpgradeCheck = 1420;
ORGANIZATIONNAME = "William Brawner"; ORGANIZATIONNAME = "William Brawner";
TargetAttributes = { TargetAttributes = {
28AC94E9233C373900BFB70A = { 28AC94E9233C373900BFB70A = {
@ -757,7 +757,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.2;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
@ -814,7 +814,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; IPHONEOS_DEPLOYMENT_TARGET = 16.2;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = ""; PRODUCT_NAME = "";
@ -834,22 +834,27 @@
CODE_SIGN_ENTITLEMENTS = Twigs/Twigs.entitlements; CODE_SIGN_ENTITLEMENTS = Twigs/Twigs.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19; CURRENT_PROJECT_VERSION = 22;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_ASSET_PATHS = "\"Twigs/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Twigs/Preview Content\"";
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Twigs/Info.plist; INFOPLIST_FILE = Twigs/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; INFOPLIST_KEY_CFBundleDisplayName = Twigs;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 15.0; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 16.2;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.projects.budget.ios; PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.projects.budget.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6"; TARGETED_DEVICE_FAMILY = "1,2,6";
}; };
@ -864,21 +869,26 @@
CODE_SIGN_ENTITLEMENTS = Twigs/Twigs.entitlements; CODE_SIGN_ENTITLEMENTS = Twigs/Twigs.entitlements;
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development"; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 19; CURRENT_PROJECT_VERSION = 22;
DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES; DERIVE_MACCATALYST_PRODUCT_BUNDLE_IDENTIFIER = YES;
DEVELOPMENT_ASSET_PATHS = "\"Twigs/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"Twigs/Preview Content\"";
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
INFOPLIST_FILE = Twigs/Info.plist; INFOPLIST_FILE = Twigs/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 15.0; INFOPLIST_KEY_CFBundleDisplayName = Twigs;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 15.0; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance";
IPHONEOS_DEPLOYMENT_TARGET = 16.2;
"IPHONEOS_DEPLOYMENT_TARGET[sdk=macosx*]" = 16.2;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
); );
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.projects.budget.ios; PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.projects.budget.ios;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SUPPORTS_MACCATALYST = YES; SUPPORTS_MACCATALYST = YES;
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2,6"; TARGETED_DEVICE_FAMILY = "1,2,6";
}; };
@ -892,7 +902,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
INFOPLIST_FILE = TwigsTests/Info.plist; INFOPLIST_FILE = TwigsTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -902,7 +912,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Twigs.app/Twigs"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Twigs.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Twigs";
}; };
name = Debug; name = Debug;
}; };
@ -914,7 +924,7 @@
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = 9Z6DE6KNJ9; DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
INFOPLIST_FILE = TwigsTests/Info.plist; INFOPLIST_FILE = TwigsTests/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 13.0; IPHONEOS_DEPLOYMENT_TARGET = 15.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"@executable_path/Frameworks", "@executable_path/Frameworks",
@ -924,7 +934,7 @@
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Twigs.app/Twigs"; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Twigs.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Twigs";
}; };
name = Release; name = Release;
}; };
@ -942,9 +952,10 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.BudgetUITests; PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.BudgetUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Budget; TEST_TARGET_NAME = Twigs;
}; };
name = Debug; name = Debug;
}; };
@ -962,9 +973,10 @@
); );
PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.BudgetUITests; PRODUCT_BUNDLE_IDENTIFIER = com.wbrawner.BudgetUITests;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE = "";
SWIFT_VERSION = 5.0; SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2"; TARGETED_DEVICE_FAMILY = "1,2";
TEST_TARGET_NAME = Budget; TEST_TARGET_NAME = Twigs;
}; };
name = Release; name = Release;
}; };
@ -974,7 +986,8 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = VJ33S6H7W7; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
MACOSX_DEPLOYMENT_TARGET = 12.1; MACOSX_DEPLOYMENT_TARGET = 12.1;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
@ -989,7 +1002,8 @@
CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17";
CODE_SIGN_IDENTITY = "-"; CODE_SIGN_IDENTITY = "-";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
DEVELOPMENT_TEAM = VJ33S6H7W7; DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 9Z6DE6KNJ9;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
MACOSX_DEPLOYMENT_TARGET = 12.1; MACOSX_DEPLOYMENT_TARGET = 12.1;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1340" LastUpgradeVersion = "1420"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1340" LastUpgradeVersion = "1420"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"

View file

@ -6,6 +6,7 @@
// Copyright © 2019 William Brawner. All rights reserved. // Copyright © 2019 William Brawner. All rights reserved.
// //
import Charts
import SwiftUI import SwiftUI
import TwigsCore import TwigsCore
@ -21,14 +22,50 @@ struct BudgetDetailsView: View {
errorTextLocalizedStringKey: "budgets_load_failure" errorTextLocalizedStringKey: "budgets_load_failure"
) { overview in ) { overview in
List { List {
Section(overview.budget.name) { if let description = overview.budget.description {
DescriptionOverview(overview: overview) Section(overview.budget.name) {
Text(description)
}
} }
Section("income") {
IncomeOverview(overview: overview) Section("stats") {
HStack {
VStack(alignment: .center) {
Text("cash_flow")
.font(.caption)
Text(verbatim: overview.balance.toCurrencyString())
.foregroundColor(overview.balance < 0 ? .red : .green)
.font(.title2)
}
Spacer()
VStack(alignment: .center) {
Text("transactions")
.font(.caption)
Text(verbatim: String(overview.transactionCount))
.font(.title2)
}
}
} }
Section("expenses") { Section("expected_vs_actual") {
ExpensesOverview(overview: overview) Chart {
BarMark(
x: .value("Amount", overview.expectedIncome),
y: .value("Label", Bundle.main.localizedString(forKey: "expected_income", value: nil, table: nil))
).foregroundStyle(Color.gray)
BarMark(
x: .value("Amount", overview.actualIncome),
y: .value("Label", Bundle.main.localizedString(forKey: "actual_income", value: nil, table: nil))
).foregroundStyle(Color.green)
BarMark(
x: .value("Amount", overview.expectedExpenses),
y: .value("Label", Bundle.main.localizedString(forKey: "expected_expenses", value: nil, table: nil))
).foregroundStyle(Color.gray)
BarMark(
x: .value("Amount", overview.actualExpenses),
y: .value("Label", Bundle.main.localizedString(forKey: "actual_expenses", value: nil, table: nil))
).foregroundStyle(Color.red)
}
} }
} }
.listStyle(.insetGrouped) .listStyle(.insetGrouped)
@ -61,63 +98,6 @@ struct BudgetDetailsView: View {
} }
} }
struct DescriptionOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
if let description = overview.budget.description {
Text(description)
}
HStack {
Text("current_balance")
Text(verbatim: overview.balance.toCurrencyString())
.foregroundColor(overview.balance < 0 ? .red : .green)
}
}
}
}
struct IncomeOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedIncome.toCurrencyString())
}
ProgressView(value: Float(overview.expectedIncome), maxValue: overview.maxValue, progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualIncome.toCurrencyString())
.foregroundColor(.green)
}
ProgressView(value: Float(overview.actualIncome), maxValue: overview.maxValue, progressTintColor: .green, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
}
}
}
struct ExpensesOverview: View {
let overview: BudgetOverview
var body: some View {
VStack(alignment: .leading) {
HStack {
Text("expected")
Text(verbatim: overview.expectedExpenses.toCurrencyString())
}
ProgressView(value: Float(overview.expectedExpenses), maxValue: overview.maxValue, progressTintColor: .gray, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
HStack {
Text("actual")
Text(verbatim: overview.actualExpenses.toCurrencyString())
.foregroundColor(.red)
}
ProgressView(value: Float(overview.actualExpenses), maxValue: overview.maxValue, progressTintColor: .red, progressBarHeight: 10.0, progressBarCornerRadius: 4.0)
}
}
}
#if DEBUG #if DEBUG
struct BudgetDetailsView_Previews: PreviewProvider { struct BudgetDetailsView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {

View file

@ -178,7 +178,12 @@ class DataStore : ObservableObject {
do { do {
let budgetBalance = try await self.apiService.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil) let budgetBalance = try await self.apiService.sumTransactions(budgetId: budget.id, categoryId: nil, from: nil, to: nil)
let categories = try await self.apiService.getCategories(budgetId: budget.id, expense: nil, archived: false, count: nil, page: nil) let categories = try await self.apiService.getCategories(budgetId: budget.id, expense: nil, archived: false, count: nil, page: nil)
var budgetOverview = BudgetOverview(budget: budget, balance: budgetBalance.balance) await self.getTransactions(showLoader: false)
var transactionCount = 0
if case let .success(transactions) = self.transactions {
transactionCount = transactions.count
}
var budgetOverview = BudgetOverview(budget: budget, balance: budgetBalance.balance, categories: categories, transactionCount: transactionCount)
try await withThrowingTaskGroup(of: (TwigsCore.Category, BalanceResponse).self) { group in try await withThrowingTaskGroup(of: (TwigsCore.Category, BalanceResponse).self) { group in
for category in categories { for category in categories {
group.addTask { group.addTask {
@ -188,15 +193,15 @@ class DataStore : ObservableObject {
for try await (category, response) in group { for try await (category, response) in group {
if category.expense { if category.expense {
budgetOverview.expectedExpenses += category.amount budgetOverview.expectedExpenses += Float(category.amount) / 100.0
} else { } else {
budgetOverview.expectedIncome += category.amount budgetOverview.expectedIncome += Float(category.amount) / 100.0
} }
if category.expense { if category.expense {
budgetOverview.actualExpenses += abs(response.balance) budgetOverview.actualExpenses += Float(abs(response.balance)) / 100.0
} else { } else {
budgetOverview.actualIncome += response.balance budgetOverview.actualIncome += Float(response.balance) / 100.0
} }
} }
} }

View file

@ -66,10 +66,13 @@
"budgets" = "Budgets"; "budgets" = "Budgets";
"new_budget" = "New Budget"; "new_budget" = "New Budget";
"current_balance" = "Current Balance:"; "current_balance" = "Current Balance:";
"expected" = "Expected:"; "stats" = "Stats";
"actual" = "Actual:"; "cash_flow" = "Cash Flow";
"income" = "Income:"; "expected_vs_actual" = "Expected vs Actual";
"expenses" = "Expenses:"; "expected_income" = "Expected Income";
"expected_expenses" = "Expected Expenses";
"actual_income" = "Actual Income";
"actual_expenses" = "Actual Expenses";
// MARK: Profile // MARK: Profile
"profile" = "Profile"; "profile" = "Profile";

View file

@ -65,11 +65,13 @@
"budget" = "Presupuesto"; "budget" = "Presupuesto";
"budgets" = "Presupuestos"; "budgets" = "Presupuestos";
"new_budget" = "Nuevo Presupuesto"; "new_budget" = "Nuevo Presupuesto";
"current_balance" = "Saldo Actual:"; "stats" = "Estadísticas";
"income" = "Ingresos:"; "cash_flow" = "Flujo de dinero";
"expenses" = "Gastos:"; "expected_vs_actual" = "Planeado vs Actual";
"expected" = "Planeados:"; "expected_income" = "Ingresos Planeados";
"actual" = "Actuales:"; "expected_expenses" = "Gastos Planeados";
"actual_income" = "Ingresos Actuales";
"actual_expenses" = "Gastos Actuales";
// MARK: Profile // MARK: Profile