From 607493fdd02be42814d48de68284c24e6f742186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CSamoilenkoVadym=E2=80=9D?= <“samoylenko.vadym@gmail.com”> Date: Sun, 2 Mar 2025 23:04:42 +0000 Subject: [PATCH] Add category and budget models --- Coinly/Extensions/Color+Extensions.swift | 0 .../Features/Budget/BudgetSettingsView.swift | 125 ++++++++++++++++++ .../Features/Categories/AddCategoryView.swift | 90 +++++++++++++ .../Categories/CategoryListView.swift | 91 +++++++++++++ Coinly/Features/Models/BudgetModel.swift | 40 ++++++ Coinly/Features/Models/CategoryStore.swift | 60 +++++++++ Coinly/UI/Components/CategoryRowView.swift | 29 ++++ 7 files changed, 435 insertions(+) create mode 100644 Coinly/Extensions/Color+Extensions.swift create mode 100644 Coinly/Features/Budget/BudgetSettingsView.swift create mode 100644 Coinly/Features/Categories/AddCategoryView.swift create mode 100644 Coinly/Features/Categories/CategoryListView.swift create mode 100644 Coinly/Features/Models/BudgetModel.swift create mode 100644 Coinly/Features/Models/CategoryStore.swift create mode 100644 Coinly/UI/Components/CategoryRowView.swift diff --git a/Coinly/Extensions/Color+Extensions.swift b/Coinly/Extensions/Color+Extensions.swift new file mode 100644 index 0000000..e69de29 diff --git a/Coinly/Features/Budget/BudgetSettingsView.swift b/Coinly/Features/Budget/BudgetSettingsView.swift new file mode 100644 index 0000000..f4070a7 --- /dev/null +++ b/Coinly/Features/Budget/BudgetSettingsView.swift @@ -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 + } + } + } +} \ No newline at end of file diff --git a/Coinly/Features/Categories/AddCategoryView.swift b/Coinly/Features/Categories/AddCategoryView.swift new file mode 100644 index 0000000..62c0a31 --- /dev/null +++ b/Coinly/Features/Categories/AddCategoryView.swift @@ -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) + } + } + } +} \ No newline at end of file diff --git a/Coinly/Features/Categories/CategoryListView.swift b/Coinly/Features/Categories/CategoryListView.swift new file mode 100644 index 0000000..5cc59b7 --- /dev/null +++ b/Coinly/Features/Categories/CategoryListView.swift @@ -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) + } +} \ No newline at end of file diff --git a/Coinly/Features/Models/BudgetModel.swift b/Coinly/Features/Models/BudgetModel.swift new file mode 100644 index 0000000..b80dca9 --- /dev/null +++ b/Coinly/Features/Models/BudgetModel.swift @@ -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 + } +} \ No newline at end of file diff --git a/Coinly/Features/Models/CategoryStore.swift b/Coinly/Features/Models/CategoryStore.swift new file mode 100644 index 0000000..b58b60b --- /dev/null +++ b/Coinly/Features/Models/CategoryStore.swift @@ -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 + } +} \ No newline at end of file diff --git a/Coinly/UI/Components/CategoryRowView.swift b/Coinly/UI/Components/CategoryRowView.swift new file mode 100644 index 0000000..2674739 --- /dev/null +++ b/Coinly/UI/Components/CategoryRowView.swift @@ -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() + } + } +} \ No newline at end of file