diff --git a/.gitignore b/.gitignore index 63d8a20..de011f8 100644 --- a/.gitignore +++ b/.gitignore @@ -94,3 +94,6 @@ iOSInjectionProject/ SimpleBudget.xcodeproj buildServer.json +.compile +Sources/Info.plist +Sources/simple-budget.entitlements diff --git a/Info.plist b/Info.plist deleted file mode 100644 index 9523b46..0000000 --- a/Info.plist +++ /dev/null @@ -1,16 +0,0 @@ - - - - - CFBundleExecutable - $(EXECUTABLE_NAME) - CFBundleIdentifier - com.corybuecker.SimpleBudget - CFBundleName - Simple Budget - CFBundleShortVersionString - 1 - CFBundleVersion - 1.0 - - diff --git a/Sources/Assets.xcassets/AccentColor.colorset/Contents.json b/Sources/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..35bfc1f --- /dev/null +++ b/Sources/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,20 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.227", + "green" : "0.160", + "red" : "0.127" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ebf6057 --- /dev/null +++ b/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "piggy_bank_icon_1024x1024.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/Assets.xcassets/AppIcon.appiconset/piggy_bank_icon_1024x1024.png b/Sources/Assets.xcassets/AppIcon.appiconset/piggy_bank_icon_1024x1024.png new file mode 100644 index 0000000..f9fb86d Binary files /dev/null and b/Sources/Assets.xcassets/AppIcon.appiconset/piggy_bank_icon_1024x1024.png differ diff --git a/Sources/Assets.xcassets/Contents.json b/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 626988f..21be05c 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1,13 +1,27 @@ +import SwiftData import SwiftUI struct ContentView: View { - init() { - print("test") - } + @Environment(\.modelContext) var context: ModelContext var body: some View { - return VStack { - Text("Hello, World!") + TabView { + Reports() + .tabItem { + Label("Reports", systemImage: "chart.bar") + } + AccountList() + .tabItem { + Label("Accounts", systemImage: "building.columns") + } + SavingList() + .tabItem { + Label("Savings", systemImage: "dollarsign.circle") + } + GoalList() + .tabItem { + Label("Goals", systemImage: "target") + } } } } diff --git a/Sources/Models/Account.swift b/Sources/Models/Account.swift new file mode 100644 index 0000000..c380fd5 --- /dev/null +++ b/Sources/Models/Account.swift @@ -0,0 +1,14 @@ +import SwiftData + +@Model +class Account { + var name: String = "" + var balance: Double = 0.0 + var debt: Bool = false + + init(name: String, balance: Double, debt: Bool) { + self.name = name + self.balance = balance + self.debt = debt + } +} diff --git a/Sources/Models/Goal.swift b/Sources/Models/Goal.swift new file mode 100644 index 0000000..77df9a6 --- /dev/null +++ b/Sources/Models/Goal.swift @@ -0,0 +1,24 @@ +import SwiftData +import SwiftUI + +enum GoalRecurrence: Codable { + case daily + case weekly + case monthly + case yearly +} + +@Model +class Goal { + var name: String = "" + var amount: Double = 0.0 + var recurrence: GoalRecurrence = GoalRecurrence.monthly + var targetDate: Date = Date() + + init(name: String, amount: Double, recurrence: GoalRecurrence, targetDate: Date) { + self.name = name + self.amount = amount + self.recurrence = recurrence + self.targetDate = targetDate + } +} diff --git a/Sources/Models/Saving.swift b/Sources/Models/Saving.swift new file mode 100644 index 0000000..4b323be --- /dev/null +++ b/Sources/Models/Saving.swift @@ -0,0 +1,12 @@ +import SwiftData + +@Model +class Saving { + var name: String = "" + var amount: Double = 0.0 + + init(name: String, amount: Double) { + self.name = name + self.amount = amount + } +} diff --git a/Sources/Services/DateService.swift b/Sources/Services/DateService.swift new file mode 100644 index 0000000..8e44e1e --- /dev/null +++ b/Sources/Services/DateService.swift @@ -0,0 +1,92 @@ +import SwiftUI + +struct DateService { + enum DateServiceError: Error { + case unexpectedDate + } + + private let from: Date + private let calendar: Calendar + + init() { + self.from = Date() + self.calendar = Calendar.current + } + + init(_ from: Date) { + self.from = from + self.calendar = Calendar.current + } + + func startOfMonth() throws -> Date { + let components = self.calendar.dateComponents([.year, .month], from: self.from) + guard let optDate = self.calendar.date(from: components) else { + throw DateServiceError.unexpectedDate + } + return optDate + } + + func endOfMonth() throws -> Date { + let startOfMonth = try startOfMonth() + let components = DateComponents(month: 1, second: -1) + guard let endOfMonth = self.calendar.date(byAdding: components, to: startOfMonth) else { + throw DateServiceError.unexpectedDate + } + return endOfMonth + } + + func startOfNextMonth() throws -> Date { + let addComponents = DateComponents(month: 1) + + guard let nextMonth = self.calendar.date(byAdding: addComponents, to: self.from) else { + throw DateServiceError.unexpectedDate + } + + let nextMonthComponents = self.calendar.dateComponents([.year, .month], from: nextMonth) + + guard let optDate = self.calendar.date(from: nextMonthComponents) else { + throw DateServiceError.unexpectedDate + } + return optDate + } + + func endOfNextMonth() throws -> Date { + let startOfNextMonth = try startOfNextMonth() + let components = DateComponents(month: 1, second: -1) + guard let endOfNextMonth = self.calendar.date(byAdding: components, to: startOfNextMonth) else { + throw DateServiceError.unexpectedDate + } + return endOfNextMonth + } + + func isEndOfMonth() throws -> Bool { + let dateComponents = self.calendar.dateComponents([.year, .month, .day], from: self.from) + let endOfMonthComponents = try self.calendar.dateComponents( + [.year, .month, .day], from: endOfMonth()) + + return dateComponents.year == endOfMonthComponents.year + && dateComponents.month == endOfMonthComponents.month + && dateComponents.day == endOfMonthComponents.day + } + + func daysUntilEndOfMonth() throws -> Int { + if try isEndOfMonth() { + guard + let days = try self.calendar.dateComponents( + [.day], from: self.from, to: endOfNextMonth() + ).day + else { + throw DateServiceError.unexpectedDate + } + return days + } + + guard + let days = try self.calendar.dateComponents([.day], from: self.from, to: endOfMonth()) + .day + else { + throw DateServiceError.unexpectedDate + } + return days + } +} diff --git a/Sources/Services/GoalService.swift b/Sources/Services/GoalService.swift new file mode 100644 index 0000000..4e7dbd8 --- /dev/null +++ b/Sources/Services/GoalService.swift @@ -0,0 +1,52 @@ +import SwiftUI + +struct GoalService { + private let goal: Goal + private let day: Double = 60 * 60 * 24.0 + + init(goal: Goal) { + self.goal = goal + } + + func amortized() -> Decimal { + if Date() >= self.goal.targetDate { + return Decimal(self.goal.amount) + } + + return dailyAmount() * Decimal(elapsedDays()) + } + + func dailyAmount() -> Decimal { + if Date() >= self.goal.targetDate { + return Decimal(0) + } + + if Date() < startDate() { + return Decimal(0) + } + + return Decimal(self.goal.amount) + / Decimal(DateInterval(start: startDate(), end: self.goal.targetDate).duration / day) + } + + private func elapsedDays() -> Double { + if Date() < startDate() { + return 0 + } + + return DateInterval(start: startDate(), end: Date()).duration / day + } + + private func startDate() -> Date { + switch self.goal.recurrence { + case GoalRecurrence.daily: + return Calendar.current.date(byAdding: .day, value: -1, to: self.goal.targetDate)! + case GoalRecurrence.weekly: + return Calendar.current.date(byAdding: .weekOfYear, value: -1, to: self.goal.targetDate)! + case GoalRecurrence.monthly: + return Calendar.current.date(byAdding: .month, value: -1, to: self.goal.targetDate)! + case GoalRecurrence.yearly: + return Calendar.current.date(byAdding: .year, value: -1, to: self.goal.targetDate)! + } + } +} diff --git a/Sources/SimpleBudget.swift b/Sources/SimpleBudget.swift index 76d5a6a..aa2db65 100644 --- a/Sources/SimpleBudget.swift +++ b/Sources/SimpleBudget.swift @@ -1,10 +1,11 @@ +import SwiftData import SwiftUI @main struct SimpleBudget: App { - var body: some Scene { - WindowGroup { - ContentView() - } + var body: some Scene { + WindowGroup { + ContentView().modelContainer(for: [Account.self, Saving.self, Goal.self]) } + } } diff --git a/Sources/Views/AccountForm.swift b/Sources/Views/AccountForm.swift new file mode 100644 index 0000000..88e584c --- /dev/null +++ b/Sources/Views/AccountForm.swift @@ -0,0 +1,43 @@ +import SwiftData +import SwiftUI + +struct AccountForm: View { + let account: Account? + + @State private var name: String = "" + @State private var balance: Double = 0.0 + @State private var debt: Bool = false + + @Environment(\.modelContext) var modelContext: ModelContext + @Environment(\.dismiss) private var dismiss + + var body: some View { + Form { + TextField("Name", text: $name) + TextField("Balance", value: $balance, format: .number) + Picker("Debt", selection: $debt) { + Text("No").tag(false) + Text("Yes").tag(true) + }.pickerStyle(.segmented) + }.navigationBarTitle("New Account").toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + if let account { + account.name = name + account.balance = balance + account.debt = debt + } else { + modelContext.insert(Account(name: name, balance: balance, debt: debt)) + } + dismiss() + } + } + }.onAppear { + if let account { + name = account.name + balance = account.balance + debt = account.debt + } + } + } +} diff --git a/Sources/Views/AccountList.swift b/Sources/Views/AccountList.swift new file mode 100644 index 0000000..1d4d40b --- /dev/null +++ b/Sources/Views/AccountList.swift @@ -0,0 +1,34 @@ +import SwiftData +import SwiftUI + +struct AccountList: View { + @Environment(\.modelContext) var modelContext: ModelContext + @Query private var accounts: [Account] + + var body: some View { + VStack { + NavigationStack { + List { + ForEach(accounts, id: \.self) { account in + NavigationLink(destination: AccountForm(account: account)) { + Text(account.name) + } + }.onDelete(perform: deleteAccount) + }.navigationBarTitle("Accounts").toolbar { + ToolbarItem { + NavigationLink(destination: AccountForm(account: nil)) { + Image(systemName: "plus") + } + } + } + } + Spacer() + } + } + + func deleteAccount(at offsets: IndexSet) { + for offset in offsets { + modelContext.delete(accounts[offset]) + } + } +} diff --git a/Sources/Views/GoalForm.swift b/Sources/Views/GoalForm.swift new file mode 100644 index 0000000..09beb69 --- /dev/null +++ b/Sources/Views/GoalForm.swift @@ -0,0 +1,53 @@ +import SwiftData +import SwiftUI + +struct GoalForm: View { + let goal: Goal? + + @State private var name: String = "" + @State private var amount: Double = 0.0 + @State private var recurrence: GoalRecurrence = .monthly + @State private var targetDate: Date = Date() + + @Environment(\.modelContext) var modelContext: ModelContext + @Environment(\.dismiss) var dismiss + + var body: some View { + Form { + TextField("Name", text: $name) + TextField("Amount", value: $amount, format: .number) + Picker("Recurrance", selection: $recurrence) { + Text("Daily").tag(GoalRecurrence.daily) + Text("Weekly").tag(GoalRecurrence.weekly) + Text("Monthly").tag(GoalRecurrence.monthly) + Text("Yearly").tag(GoalRecurrence.yearly) + } + DatePicker("Target Date", selection: $targetDate, displayedComponents: [.date]) + }.navigationBarTitle(goal?.name ?? "New Goal").toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + let startOfDay = Calendar.current.startOfDay(for: targetDate) + + if let goal = goal { + goal.name = name + goal.amount = amount + goal.recurrence = recurrence + goal.targetDate = startOfDay + } else { + modelContext.insert( + Goal(name: name, amount: amount, recurrence: recurrence, targetDate: startOfDay)) + } + + dismiss() + } + } + }.onAppear { + if let goal { + name = goal.name + amount = goal.amount + recurrence = goal.recurrence + targetDate = goal.targetDate + } + } + } +} diff --git a/Sources/Views/GoalList.swift b/Sources/Views/GoalList.swift new file mode 100644 index 0000000..888d0eb --- /dev/null +++ b/Sources/Views/GoalList.swift @@ -0,0 +1,67 @@ +import SwiftData +import SwiftUI + +struct GoalList: View { + @Environment(\.modelContext) var modelContext: ModelContext + @Query private var goals: [Goal] + + @State private var lastRendered: Date = Date() + + var body: some View { + VStack { + NavigationStack { + List { + ForEach(goals, id: \.self) { goal in + NavigationLink(destination: GoalForm(goal: goal)) { + GoalListItem(goal: goal, lastRendered: lastRendered) + } + } + .onDelete(perform: deleteGoal) + } + .navigationBarTitle("Goals") + .toolbar { + ToolbarItem { + NavigationLink(destination: GoalForm(goal: nil)) { + Image(systemName: "plus") + } + } + } + .refreshable { + lastRendered = Date() + } + } + Spacer() + } + } + + func deleteGoal(at offsets: IndexSet) { + for offset in offsets { + modelContext.delete(goals[offset]) + } + } +} + +struct GoalListItem: View { + let goal: Goal + let lastRendered: Date + + var body: some View { + VStack(alignment: .leading) { + Text(goal.name) + Text(goal.targetDate.formatted(date: .abbreviated, time: .omitted)) + .foregroundColor(.secondary) + HStack { + Text( + GoalService(goal: goal).dailyAmount(), + format: .currency(code: "USD").precision(.fractionLength(2))) + Text("/") + Text( + GoalService(goal: goal).amortized(), + format: .currency(code: "USD").precision(.fractionLength(5))) + Text("/") + Text(goal.amount, format: .currency(code: "USD").precision(.fractionLength(0))) + } + .foregroundColor(.secondary) + } + } +} diff --git a/Sources/Views/Reports.swift b/Sources/Views/Reports.swift new file mode 100644 index 0000000..590d57e --- /dev/null +++ b/Sources/Views/Reports.swift @@ -0,0 +1,85 @@ +import SwiftData +import SwiftUI + +struct Reports: View { + @Environment(\.modelContext) var modelContext: ModelContext + @Query var accounts: [Account] + @Query var savings: [Saving] + @Query var goals: [Goal] + + var total: Decimal { + let accountsTotal: Decimal = accounts.reduce( + 0, + { accumulator, account in + account.debt + ? accumulator - Decimal(account.balance) : accumulator + Decimal(account.balance) + }) + + let savingsTotal: Decimal = savings.reduce( + 0, + { accumulator, saving in + accumulator - Decimal(saving.amount) + }) + + let goalsTotal: Decimal = goals.reduce( + 0, + { accumulator, goal in + accumulator - GoalService(goal: goal).amortized() + }) + + return accountsTotal + savingsTotal + goalsTotal + } + + var daysRemaining: Int? = try? DateService().daysUntilEndOfMonth() + + func dailySaving() -> Decimal { + goals.reduce( + 0, + { accumulator, goal in + accumulator + GoalService(goal: goal).dailyAmount() + }) + } + + func remainingAmount() -> Decimal { + if let daysRemaining { + total / Decimal(daysRemaining) + } else { + 0 + } + } + + @State private var lastRendered = Date() + + var body: some View { + VStack { + Breakdown( + lastRendered: lastRendered, total: total, daysRemaining: daysRemaining, + remainingAmount: remainingAmount(), dailySaving: dailySaving()) + Button("Refresh") { + lastRendered = Date() + } + } + } +} + +struct Breakdown: View { + let lastRendered: Date + let total: Decimal + let daysRemaining: Int? + let remainingAmount: Decimal + let dailySaving: Decimal + + var body: some View { + Text("Reports").foregroundColor( + Color( + red: Double.random(in: 0...1), + green: Double.random(in: 0...1), + blue: Double.random(in: 0...1))) + Text(total, format: .currency(code: "USD")) + if let daysRemaining { + Text("Days remaining: \(daysRemaining)") + } + Text(remainingAmount, format: .currency(code: "USD").precision(.fractionLength(4))) + Text(dailySaving, format: .currency(code: "USD")) + } +} diff --git a/Sources/Views/SavingForm.swift b/Sources/Views/SavingForm.swift new file mode 100644 index 0000000..c1db071 --- /dev/null +++ b/Sources/Views/SavingForm.swift @@ -0,0 +1,37 @@ +import SwiftData +import SwiftUI + +struct SavingForm: View { + let saving: Saving? + + @State private var name: String = "" + @State private var amount: Double = 0.0 + + @Environment(\.modelContext) var modelContext: ModelContext + @Environment(\.dismiss) var dismiss + + var body: some View { + Form { + TextField("Name", text: $name) + TextField("Amount", value: $amount, format: .number) + }.navigationBarTitle(saving?.name ?? "New Saving").toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button("Save") { + if let saving = saving { + saving.name = name + saving.amount = amount + } else { + modelContext.insert(Saving(name: name, amount: amount)) + } + + dismiss() + } + } + }.onAppear { + if let saving { + name = saving.name + amount = saving.amount + } + } + } +} diff --git a/Sources/Views/SavingList.swift b/Sources/Views/SavingList.swift new file mode 100644 index 0000000..0ef7890 --- /dev/null +++ b/Sources/Views/SavingList.swift @@ -0,0 +1,34 @@ +import SwiftData +import SwiftUI + +struct SavingList: View { + @Environment(\.modelContext) var modelContext: ModelContext + @Query private var savings: [Saving] + + var body: some View { + VStack { + NavigationStack { + List { + ForEach(savings, id: \.self) { saving in + NavigationLink(destination: SavingForm(saving: saving)) { + Text(saving.name) + } + }.onDelete(perform: deleteSaving) + }.navigationBarTitle("Savings").toolbar { + ToolbarItem { + NavigationLink(destination: SavingForm(saving: nil)) { + Image(systemName: "plus") + } + } + } + } + Spacer() + } + } + + func deleteSaving(at offsets: IndexSet) { + for offset in offsets { + modelContext.delete(savings[offset]) + } + } +} diff --git a/launch.sh b/launch.sh index 1c7f9ab..d9ab73e 100755 --- a/launch.sh +++ b/launch.sh @@ -1,14 +1,7 @@ #!/bin/bash -ex -#rm -rf SimpleBudget.xcodeproj -# -#xcodegen -# -#xcodebuild clean build -project SimpleBudget.xcodeproj -allowProvisioningUpdates -sdk iphonesimulator -#xcrun simctl install MyPhone build/Debug-iphonesimulator/SimpleBudget.app - -#xcode-build-server config -project SimpleBudget.xcodeproj -scheme SimpleBudget - -xcodebuild clean build -project SimpleBudget.xcodeproj -allowProvisioningUpdates -sdk iphonesimulator -configuration Debug -xcrun simctl install MyPhone build/Debug-iphonesimulator/SimpleBudget.app -xcrun simctl launch --console-pty --terminate-running-process MyPhone com.corybuecker.SimpleBudget +xcodegen +xcodebuild build -project SimpleBudget.xcodeproj -sdk iphonesimulator -configuration Debug -scheme simple-budget -derivedDataPath ./.build | xcode-build-server parse -a +#xcrun simctl uninstall MyPhone com.corybuecker.SimpleBudget +xcrun simctl install MyPhone .build/Build/Products/Debug-iphonesimulator/simple-budget.app +xcrun simctl launch --console-pty --terminate-running-process MyPhone dev.corybuecker.simple-budget diff --git a/project.yml b/project.yml index 3c98bcb..53cb2a2 100644 --- a/project.yml +++ b/project.yml @@ -1,13 +1,34 @@ name: SimpleBudget options: - bundleIdPrefix: com.corybuecker + bundleIdPrefix: dev.corybuecker settings: DEVELOPMENT_TEAM: ${DEVELOPMENT_TEAM} - INFOPLIST_FILE: Info.plist targets: - SimpleBudget: + simple-budget: type: application platform: iOS deploymentTarget: "17.2" + supportedDestinations: [iOS] sources: - - Sources + - path: Sources + info: + path: Sources/Info.plist + properties: + CFBundleDevelopmentRegion: en + CFBundleIdentifier: dev.corybuecker.simple-budget + CFBundleName: Simple Budget + CFBundlePackageType: APPL + CFBundleShortVersionString: 1.0.1 + CFBundleVersion: 1.0.1 + ITSAppUsesNonExemptEncryption: false + UIBackgroundModes: [remote-notification] + UILaunchScreen: {} + UIRequiresFullScreen: true + UISupportedInterfaceOrientations: [UIInterfaceOrientationPortrait] + entitlements: + path: Sources/simple-budget.entitlements + properties: + aps-environment: development + com.apple.developer.icloud-container-identifiers: [iCloud.dev.corybuecker.simple-budget] + com.apple.developer.icloud-services: [CloudKit] +