Skip to content

Instantly share code, notes, and snippets.

@raxityo
Created February 25, 2025 17:51
Show Gist options
  • Save raxityo/ff64959fd11977cfda669fca93230dbf to your computer and use it in GitHub Desktop.
Save raxityo/ff64959fd11977cfda669fca93230dbf to your computer and use it in GitHub Desktop.
import Charts
import SwiftUI
// Models
struct Expense: Identifiable, Hashable, Codable {
var id = UUID()
var title: String
var amount: Double
var date: Date
var category: Category
var notes: String = ""
static var sampleData: [Expense] = [
Expense(title: "Groceries", amount: 54.96, date: Date().addingTimeInterval(-86400), category: .food),
Expense(title: "Movie Tickets", amount: 24.99, date: Date().addingTimeInterval(-172_800), category: .entertainment),
Expense(title: "Gas", amount: 45.63, date: Date().addingTimeInterval(-259_200), category: .transportation),
]
}
enum Category: String, CaseIterable, Identifiable, Codable {
case food = "Food"
case transportation = "Transportation"
case entertainment = "Entertainment"
case utilities = "Utilities"
case housing = "Housing"
case healthcare = "Healthcare"
case other = "Other"
var id: String { rawValue }
var icon: String {
switch self {
case .food: return "cart.fill"
case .transportation: return "car.fill"
case .entertainment: return "film.fill"
case .utilities: return "bolt.fill"
case .housing: return "house.fill"
case .healthcare: return "heart.fill"
case .other: return "square.fill"
}
}
var color: Color {
switch self {
case .food: return .green
case .transportation: return .blue
case .entertainment: return .purple
case .utilities: return .yellow
case .housing: return .brown
case .healthcare: return .red
case .other: return .gray
}
}
}
class ExpenseStore: ObservableObject {
@Published var expenses: [Expense] = []
init() {
if let savedExpenses = UserDefaults.standard.data(forKey: "SavedExpenses") {
if let decodedExpenses = try? JSONDecoder().decode([Expense].self, from: savedExpenses) {
expenses = decodedExpenses
return
}
}
// Fallback to sample data if no saved data
expenses = Expense.sampleData
}
func save() {
if let encoded = try? JSONEncoder().encode(expenses) {
UserDefaults.standard.set(encoded, forKey: "SavedExpenses")
}
}
}
struct CategoryTotal: Identifiable {
var category: Category
var total: Double
var id: String { category.id }
}
struct ContentView: View {
@StateObject private var expenseStore = ExpenseStore()
@State private var showingAddExpense = false
@State private var selectedCategory: Category? = nil
@State private var showChartView = false
var filteredExpenses: [Expense] {
if let category = selectedCategory {
return expenseStore.expenses.filter { $0.category == category }
}
return expenseStore.expenses
}
var totalAmount: Double {
filteredExpenses.reduce(0) { $0 + $1.amount }
}
var categoryTotals: [CategoryTotal] {
var totals: [Category: Double] = [:]
for expense in filteredExpenses {
totals[expense.category, default: 0] += expense.amount
}
return totals.map { CategoryTotal(category: $0.key, total: $0.value) }
.sorted { $0.total > $1.total }
}
var body: some View {
NavigationStack {
VStack {
// Category Filter
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 15) {
CategoryButton(title: "All", icon: "square.grid.2x2.fill", color: .gray, isSelected: selectedCategory == nil) {
selectedCategory = nil
}
ForEach(Category.allCases) { category in
CategoryButton(
title: category.rawValue,
icon: category.icon,
color: category.color,
isSelected: selectedCategory == category
) {
selectedCategory = category
}
}
}
.padding(.horizontal)
}
.padding(.vertical, 8)
// Total Amount and chart toggle
HStack {
Text("Total:")
.font(.headline)
Spacer()
Text("$\(totalAmount, specifier: "%.2f")")
.font(.headline)
.foregroundColor(.primary)
Button {
showChartView.toggle()
} label: {
Image(systemName: showChartView ? "list.bullet" : "chart.pie.fill")
.imageScale(.large)
}
}
.padding(.horizontal)
.padding(.top, 4)
// Chart or List View
if showChartView {
ExpenseChartView(categoryTotals: categoryTotals)
.frame(height: 300)
.padding()
} else {
// Expense List
List {
ForEach(filteredExpenses) { expense in
ExpenseRow(expense: expense)
.swipeActions {
Button(role: .destructive) {
deleteExpense(expense)
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
}
}
.navigationTitle("Expense Tracker")
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
showingAddExpense = true
} label: {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddExpense) {
AddExpenseView(expenses: $expenseStore.expenses, onSave: {
expenseStore.save()
})
}
}
}
func deleteExpense(_ expense: Expense) {
if let index = expenseStore.expenses.firstIndex(of: expense) {
expenseStore.expenses.remove(at: index)
expenseStore.save()
}
}
}
struct ExpenseChartView: View {
let categoryTotals: [CategoryTotal]
var body: some View {
VStack {
if categoryTotals.isEmpty {
Text("No expenses to display")
.foregroundColor(.secondary)
.padding()
} else {
Chart(categoryTotals) { category in
SectorMark(
angle: .value("Amount", category.total),
innerRadius: .ratio(0.5),
angularInset: 1.5
)
.foregroundStyle(category.category.color)
.cornerRadius(5)
.annotation(position: .overlay) {
Text(category.category.rawValue)
.font(.caption)
.foregroundColor(.white)
.padding(5)
.lineLimit(1)
.minimumScaleFactor(0.5)
}
}
// Category legend
VStack {
ForEach(categoryTotals) { category in
HStack {
Circle()
.fill(category.category.color)
.frame(width: 10, height: 10)
Text(category.category.rawValue)
.font(.caption)
Spacer()
Text("$\(category.total, specifier: "%.2f")")
.font(.caption)
.foregroundColor(.secondary)
}
}
}
.padding(.top)
}
}
}
}
struct CategoryButton: View {
let title: String
let icon: String
let color: Color
let isSelected: Bool
let action: () -> Void
var body: some View {
Button(action: action) {
VStack {
Image(systemName: icon)
.font(.system(size: 24))
.foregroundColor(isSelected ? .white : color)
.frame(width: 44, height: 44)
.background(isSelected ? color : Color.gray.opacity(0.1))
.clipShape(Circle())
Text(title)
.font(.caption)
.foregroundColor(isSelected ? color : .primary)
}
}
}
}
struct ExpenseRow: View {
let expense: Expense
var body: some View {
HStack(spacing: 16) {
Image(systemName: expense.category.icon)
.foregroundColor(.white)
.frame(width: 36, height: 36)
.background(expense.category.color)
.clipShape(Circle())
VStack(alignment: .leading, spacing: 4) {
Text(expense.title)
.font(.headline)
HStack {
Text(expense.category.rawValue)
.font(.caption)
.foregroundColor(.secondary)
Text("•")
.foregroundColor(.secondary)
Text(expense.date, style: .date)
.font(.caption)
.foregroundColor(.secondary)
}
}
Spacer()
Text("$\(expense.amount, specifier: "%.2f")")
.font(.headline)
.foregroundColor(.primary)
}
.padding(.vertical, 8)
}
}
struct AddExpenseView: View {
@Environment(\.dismiss) private var dismiss
@Binding var expenses: [Expense]
var onSave: () -> Void
@State private var title = ""
@State private var amount = ""
@State private var category: Category = .food
@State private var date = Date()
@State private var notes = ""
var body: some View {
NavigationStack {
Form {
Section(header: Text("Expense Details")) {
TextField("Title", text: $title)
TextField("Amount", text: $amount)
#if os(iOS)
.keyboardType(.decimalPad)
#endif
Picker("Category", selection: $category) {
ForEach(Category.allCases) { category in
Label(
title: { Text(category.rawValue) },
icon: { Image(systemName: category.icon).foregroundColor(category.color) }
).tag(category)
}
}
DatePicker("Date", selection: $date, displayedComponents: .date)
}
Section(header: Text("Notes")) {
TextEditor(text: $notes)
.frame(minHeight: 100)
}
}
.navigationTitle("Add Expense")
#if os(iOS)
.navigationBarTitleDisplayMode(.inline)
#endif
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") {
dismiss()
}
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") {
guard let amountDouble = Double(amount), !title.isEmpty else { return }
let newExpense = Expense(
title: title,
amount: amountDouble,
date: date,
category: category,
notes: notes
)
expenses.append(newExpense)
onSave()
dismiss()
}
.disabled(title.isEmpty || amount.isEmpty)
}
}
}
}
}
#Preview {
ContentView()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment