Add category and budget models
This commit is contained in:
parent
d7d8de38f1
commit
607493fdd0
7 changed files with 435 additions and 0 deletions
0
Coinly/Extensions/Color+Extensions.swift
Normal file
0
Coinly/Extensions/Color+Extensions.swift
Normal file
125
Coinly/Features/Budget/BudgetSettingsView.swift
Normal file
125
Coinly/Features/Budget/BudgetSettingsView.swift
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import SwiftUI
|
||||
|
||||
struct BudgetSettingsView: View {
|
||||
@ObservedObject var categoryStore: CategoryStore
|
||||
@State private var selectedCategory: CategoryModel?
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Section {
|
||||
NavigationLink {
|
||||
MonthlyBudgetView(categoryStore: categoryStore)
|
||||
} label: {
|
||||
HStack {
|
||||
Text("Monthly Budget")
|
||||
Spacer()
|
||||
Text("2000")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Category Budgets") {
|
||||
ForEach(categoryStore.getCategories(of: .expense)) { category in
|
||||
HStack {
|
||||
CategoryRowView(category: category)
|
||||
|
||||
Button {
|
||||
selectedCategory = category
|
||||
} label: {
|
||||
if let budget = categoryStore.getBudget(for: category.id) {
|
||||
Text(budget.amount.formatAsCurrency())
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Set Budget")
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Budget Settings")
|
||||
.sheet(item: $selectedCategory) { category in
|
||||
NavigationView {
|
||||
CategoryBudgetView(
|
||||
category: category,
|
||||
budget: categoryStore.getBudget(for: category.id)
|
||||
) { budget in
|
||||
if categoryStore.getBudget(for: category.id) != nil {
|
||||
categoryStore.updateBudget(budget)
|
||||
} else {
|
||||
categoryStore.addBudget(budget)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryBudgetView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let category: CategoryModel
|
||||
let budget: BudgetModel?
|
||||
let onSave: (BudgetModel) -> Void
|
||||
|
||||
@State private var amount: String = ""
|
||||
@State private var period: BudgetModel.BudgetPeriod = .monthly
|
||||
@State private var isRecurring = true
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section {
|
||||
CategoryRowView(category: category)
|
||||
.listRowBackground(Color.clear)
|
||||
}
|
||||
|
||||
Section("Budget Details") {
|
||||
HStack {
|
||||
Text(AppSettings.shared.selectedCurrency.symbol)
|
||||
TextField("Amount", text: $amount)
|
||||
.keyboardType(.decimalPad)
|
||||
}
|
||||
|
||||
Picker("Period", selection: $period) {
|
||||
ForEach(BudgetModel.BudgetPeriod.allCases, id: \.self) { period in
|
||||
Text(period.rawValue).tag(period)
|
||||
}
|
||||
}
|
||||
|
||||
Toggle("Recurring", isOn: $isRecurring)
|
||||
}
|
||||
}
|
||||
.navigationTitle("Category Budget")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
guard let amountValue = Double(amount) else { return }
|
||||
let budget = BudgetModel(
|
||||
categoryId: category.id,
|
||||
amount: amountValue,
|
||||
period: period,
|
||||
isRecurring: isRecurring
|
||||
)
|
||||
onSave(budget)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(amount.isEmpty)
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
if let existingBudget = budget {
|
||||
amount = existingBudget.amount.description
|
||||
period = existingBudget.period
|
||||
isRecurring = existingBudget.isRecurring
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
90
Coinly/Features/Categories/AddCategoryView.swift
Normal file
90
Coinly/Features/Categories/AddCategoryView.swift
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
import SwiftUI
|
||||
|
||||
struct AddCategoryView: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
let type: TransactionType
|
||||
let onSave: (CategoryModel) -> Void
|
||||
|
||||
@State private var name = ""
|
||||
@State private var selectedIcon = "cart.fill"
|
||||
@State private var selectedColor = "blue"
|
||||
|
||||
private let icons = [
|
||||
"cart.fill", "car.fill", "house.fill", "creditcard.fill",
|
||||
"gift.fill", "heart.fill", "tv.fill", "gamecontroller.fill",
|
||||
"airplane", "cross.fill", "doc.text.fill", "bag.fill",
|
||||
"dollarsign.circle.fill", "chart.line.uptrend.xyaxis",
|
||||
"briefcase.fill", "gift.fill", "leaf.fill", "graduationcap.fill"
|
||||
]
|
||||
|
||||
private let colors = [
|
||||
"red", "blue", "green", "yellow",
|
||||
"purple", "orange", "pink", "indigo"
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Basic Information") {
|
||||
TextField("Category Name", text: $name)
|
||||
}
|
||||
|
||||
Section("Icon") {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 6), spacing: 16) {
|
||||
ForEach(icons, id: \.self) { icon in
|
||||
Image(systemName: icon)
|
||||
.font(.title2)
|
||||
.frame(width: 44, height: 44)
|
||||
.background(selectedIcon == icon ? Color.accentColor : Color.clear)
|
||||
.foregroundColor(selectedIcon == icon ? .white : .primary)
|
||||
.cornerRadius(8)
|
||||
.onTapGesture {
|
||||
selectedIcon = icon
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Color") {
|
||||
LazyVGrid(columns: Array(repeating: GridItem(.flexible()), count: 8), spacing: 16) {
|
||||
ForEach(colors, id: \.self) { color in
|
||||
Circle()
|
||||
.fill(CategoryModel(name: "", icon: "", color: color, type: .expense).iconColor())
|
||||
.frame(width: 32, height: 32)
|
||||
.overlay {
|
||||
if selectedColor == color {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.white)
|
||||
}
|
||||
}
|
||||
.onTapGesture {
|
||||
selectedColor = color
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("New Category")
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button("Cancel") {
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button("Save") {
|
||||
let category = CategoryModel(
|
||||
name: name,
|
||||
icon: selectedIcon,
|
||||
color: selectedColor,
|
||||
type: type
|
||||
)
|
||||
onSave(category)
|
||||
dismiss()
|
||||
}
|
||||
.disabled(name.isEmpty)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
Coinly/Features/Categories/CategoryListView.swift
Normal file
91
Coinly/Features/Categories/CategoryListView.swift
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import SwiftUI
|
||||
|
||||
struct CategoryListView: View {
|
||||
@ObservedObject var categoryStore: CategoryStore
|
||||
@State private var showingAddCategory = false
|
||||
@State private var selectedType: TransactionType = .expense
|
||||
|
||||
var body: some View {
|
||||
List {
|
||||
Picker("Type", selection: $selectedType) {
|
||||
Text("Expenses").tag(TransactionType.expense)
|
||||
Text("Income").tag(TransactionType.income)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.listRowBackground(Color.clear)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
ForEach(categoryStore.getCategories(of: selectedType)) { category in
|
||||
CategoryRowView(category: category)
|
||||
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
|
||||
if !category.isDefault {
|
||||
Button(role: .destructive) {
|
||||
categoryStore.deleteCategory(category)
|
||||
} label: {
|
||||
Label("Delete", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
// Edit category
|
||||
} label: {
|
||||
Label("Edit", systemImage: "pencil")
|
||||
}
|
||||
.tint(.blue)
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Categories")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
showingAddCategory = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showingAddCategory) {
|
||||
NavigationView {
|
||||
AddCategoryView(type: selectedType) { newCategory in
|
||||
categoryStore.addCategory(newCategory)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct CategoryRowView: View {
|
||||
let category: CategoryModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: category.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(category.iconColor())
|
||||
.cornerRadius(8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(category.name)
|
||||
.font(.headline)
|
||||
|
||||
if category.isDefault {
|
||||
Text("Default")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let budget = CategoryStore.shared.getBudget(for: category.id) {
|
||||
Text(budget.amount.formatAsCurrency())
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
}
|
||||
40
Coinly/Features/Models/BudgetModel.swift
Normal file
40
Coinly/Features/Models/BudgetModel.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Foundation
|
||||
|
||||
struct BudgetModel: Identifiable, Codable {
|
||||
let id: String
|
||||
var categoryId: String
|
||||
var amount: Double
|
||||
var period: BudgetPeriod
|
||||
var startDate: Date
|
||||
var isRecurring: Bool
|
||||
|
||||
enum BudgetPeriod: String, Codable, CaseIterable {
|
||||
case weekly = "Weekly"
|
||||
case monthly = "Monthly"
|
||||
case yearly = "Yearly"
|
||||
|
||||
var durationInDays: Int {
|
||||
switch self {
|
||||
case .weekly: return 7
|
||||
case .monthly: return 30
|
||||
case .yearly: return 365
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init(
|
||||
id: String = UUID().uuidString,
|
||||
categoryId: String,
|
||||
amount: Double,
|
||||
period: BudgetPeriod = .monthly,
|
||||
startDate: Date = Date(),
|
||||
isRecurring: Bool = true
|
||||
) {
|
||||
self.id = id
|
||||
self.categoryId = categoryId
|
||||
self.amount = amount
|
||||
self.period = period
|
||||
self.startDate = startDate
|
||||
self.isRecurring = isRecurring
|
||||
}
|
||||
}
|
||||
60
Coinly/Features/Models/CategoryStore.swift
Normal file
60
Coinly/Features/Models/CategoryStore.swift
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import Foundation
|
||||
import Combine
|
||||
|
||||
class CategoryStore: ObservableObject {
|
||||
@Published private(set) var categories: [CategoryModel] = []
|
||||
@Published private(set) var budgets: [BudgetModel] = []
|
||||
|
||||
init() {
|
||||
categories = CategoryModel.defaultCategories
|
||||
}
|
||||
|
||||
// MARK: - Categories
|
||||
|
||||
func addCategory(_ category: CategoryModel) {
|
||||
categories.append(category)
|
||||
}
|
||||
|
||||
func updateCategory(_ category: CategoryModel) {
|
||||
guard let index = categories.firstIndex(where: { $0.id == category.id }) else { return }
|
||||
categories[index] = category
|
||||
}
|
||||
|
||||
func deleteCategory(_ category: CategoryModel) {
|
||||
guard !category.isDefault else { return }
|
||||
categories.removeAll { $0.id == category.id }
|
||||
}
|
||||
|
||||
func getCategories(of type: TransactionType) -> [CategoryModel] {
|
||||
categories.filter { $0.type == type }
|
||||
}
|
||||
|
||||
// MARK: - Budgets
|
||||
|
||||
func addBudget(_ budget: BudgetModel) {
|
||||
budgets.append(budget)
|
||||
}
|
||||
|
||||
func updateBudget(_ budget: BudgetModel) {
|
||||
guard let index = budgets.firstIndex(where: { $0.id == budget.id }) else { return }
|
||||
budgets[index] = budget
|
||||
}
|
||||
|
||||
func deleteBudget(_ budget: BudgetModel) {
|
||||
budgets.removeAll { $0.id == budget.id }
|
||||
}
|
||||
|
||||
func getBudget(for categoryId: String) -> BudgetModel? {
|
||||
budgets.first { $0.categoryId == categoryId }
|
||||
}
|
||||
|
||||
func getSpentPercentage(for categoryId: String, transactions: [TransactionModel]) -> Double {
|
||||
guard let budget = getBudget(for: categoryId) else { return 0 }
|
||||
|
||||
let spent = transactions
|
||||
.filter { $0.categoryId == categoryId }
|
||||
.reduce(0) { $0 + $1.amount }
|
||||
|
||||
return spent / budget.amount
|
||||
}
|
||||
}
|
||||
29
Coinly/UI/Components/CategoryRowView.swift
Normal file
29
Coinly/UI/Components/CategoryRowView.swift
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import SwiftUI
|
||||
|
||||
struct CategoryRowView: View {
|
||||
let category: CategoryModel
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
Image(systemName: category.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(.white)
|
||||
.frame(width: 36, height: 36)
|
||||
.background(category.iconColor())
|
||||
.cornerRadius(8)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(category.name)
|
||||
.font(.headline)
|
||||
|
||||
if category.isDefault {
|
||||
Text("Default")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue