├── .github
├── assets
│ ├── inpenso-logo.png
│ ├── add-expense-screenshot.png
│ ├── analytics-screenshot.png
│ ├── dashboard-screenshot.png
│ └── rounded-logo-template.html
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── PULL_REQUEST_TEMPLATE
│ └── pull_request_template.md
├── iExpense
├── Assets.xcassets
│ ├── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── AppIcon~ios-marketing 1.png
│ │ ├── AppIcon~ios-marketing 2.png
│ │ ├── AppIcon~ios-marketing.png
│ │ └── Contents.json
│ └── AccentColor.colorset
│ │ └── Contents.json
├── Utils
│ └── CurrencyCode.swift
├── iExpense.entitlements
├── Models
│ ├── Expense.swift
│ ├── SwiftDataModels.swift
│ └── Category.swift
├── ViewTests
│ ├── SegmentedControlView.swift
│ ├── MusicTabView.swift
│ ├── TestButton.swift
│ └── TestRecentSpending.swift
├── Components
│ ├── Analytics
│ │ ├── TabSelectionView.swift
│ │ ├── DailySpendingChartView.swift
│ │ ├── CategoryBreakdownView.swift
│ │ ├── SummaryCardView.swift
│ │ ├── MonthYearPicker.swift
│ │ ├── InsightsView.swift
│ │ └── MonthlyTrendsView.swift
│ ├── HapticFeedback.swift
│ ├── IconUtils.swift
│ ├── CategoryButton.swift
│ ├── CategoryGrid.swift
│ ├── CardView.swift
│ ├── DatePickerCard.swift
│ └── FormFields.swift
├── ViewModels
│ ├── ExpenseViewModel.swift
│ └── SettingsViewModel.swift
├── SiriIntents
│ └── AddExpenseSiriIntent.swift
├── iExpenseApp.swift
├── Services
│ ├── SwiftDataServiceModels.swift
│ ├── StorageService.swift
│ ├── SwiftDataService.swift
│ └── SwiftDataManager.swift
├── SwiftDataContainer.swift
└── Views
│ ├── MainTabView.swift
│ ├── SettingsView.swift
│ ├── AddExpenseView.swift
│ └── EditExpenseView.swift
├── iExpenseWidgetExtension
├── Assets.xcassets
│ ├── Contents.json
│ ├── AppIcon.appiconset
│ │ ├── AppIcon~ios-marketing 1.png
│ │ ├── AppIcon~ios-marketing 2.png
│ │ ├── AppIcon~ios-marketing.png
│ │ └── Contents.json
│ ├── AccentColor.colorset
│ │ └── Contents.json
│ └── WidgetBackground.colorset
│ │ └── Contents.json
├── iExpenseWidgetExtensionBundle.swift
├── QuickAddConfigurationIntent.swift
├── Info.plist
├── AddQuickExpenseIntent.swift
└── iExpenseWidgetExtension.swift
├── Inpenso.xcodeproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
├── xcuserdata
│ └── dragomirmindrescu.xcuserdatad
│ │ ├── xcschemes
│ │ └── xcschememanagement.plist
│ │ └── xcdebugger
│ │ └── Breakpoints_v2.xcbkptlist
└── xcshareddata
│ └── xcschemes
│ ├── Inpenso.xcscheme
│ ├── iExpense.xcscheme
│ ├── InpensoWidgetExtension.xcscheme
│ └── InpensoWidgetExtensionExtension.xcscheme
├── iExpenseWidgetExtensionExtension.entitlements
├── ProjectPlan.swift
├── LICENSE
├── README.md
├── CONTRIBUTING.md
└── PRIVACY.md
/.github/assets/inpenso-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/.github/assets/inpenso-logo.png
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/.github/assets/add-expense-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/.github/assets/add-expense-screenshot.png
--------------------------------------------------------------------------------
/.github/assets/analytics-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/.github/assets/analytics-screenshot.png
--------------------------------------------------------------------------------
/.github/assets/dashboard-screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/.github/assets/dashboard-screenshot.png
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "author" : "xcode",
4 | "version" : 1
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpense/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 1.png
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing 2.png
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/VintusS/Inpenso/HEAD/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/AppIcon~ios-marketing.png
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "colors" : [
3 | {
4 | "idiom" : "universal"
5 | }
6 | ],
7 | "info" : {
8 | "author" : "xcode",
9 | "version" : 1
10 | }
11 | }
12 |
--------------------------------------------------------------------------------
/iExpense/Utils/CurrencyCode.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CurrencyCode.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 | //
8 |
9 | import Foundation
10 |
11 | func currentCurrencyCode() -> String {
12 | Locale.current.currency?.identifier ?? "USD"
13 | }
14 |
--------------------------------------------------------------------------------
/iExpense/iExpense.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.com.vintuss.Inpenso
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/iExpense/Models/Expense.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Expense.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 |
10 | struct Expense: Identifiable, Codable, Equatable {
11 | var id: UUID = UUID()
12 | var title: String
13 | var price: Double
14 | var date: Date
15 | var category: Category
16 | }
17 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtensionExtension.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | com.apple.security.application-groups
6 |
7 | group.com.vintuss.Inpenso
8 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/iExpenseWidgetExtensionBundle.swift:
--------------------------------------------------------------------------------
1 | //
2 | // iExpenseWidgetExtensionBundle.swift
3 | // iExpenseWidgetExtension
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import WidgetKit
9 | import SwiftUI
10 |
11 | @main
12 | struct iExpenseWidgetExtensionBundle: WidgetBundle {
13 | var body: some Widget {
14 | iExpenseWidgetExtension()
15 | }
16 | }
17 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/QuickAddConfigurationIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // QuickAddConfigurationIntent.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import AppIntents
9 |
10 | struct QuickAddConfigurationIntent: WidgetConfigurationIntent {
11 | static var title: LocalizedStringResource = "Quick Add Expense Configuration"
12 |
13 | // In future you can add options here if you want user customization
14 | }
15 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | AppGroupIdentifier
6 | group.com.vintuss.Inpenso
7 | NSExtension
8 |
9 | NSExtensionPointIdentifier
10 | com.apple.widgetkit-extension
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/ProjectPlan.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ProjectPlan.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | // MARK: Idea | Description
9 | // Analytics | See total spending, spending per category, weekly charts ✅
10 | // UI/UX Polish | Make HomeView prettier, like sections by category, or a clean dashboard ✅
11 | // iCloud Sync | Sync expenses between devices automatically ❌
12 | // Widget Upgrade | Make your widget show recent expenses or top spending categories ✅
13 | // Filtering | Filter expenses by date, category, amount ✅
14 | // Budgeting | Allow user to set monthly budget limits ✅
15 | // Notifications | Remind users to log expenses daily ❌
16 |
--------------------------------------------------------------------------------
/iExpense/ViewTests/SegmentedControlView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SegmentedControlView.swift
3 | // Inpenso
4 | //
5 | // Created by Dragomir Mindrescu on 14.10.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct SegmentedControlView: View {
11 | @State private var favoriteColor = 0
12 |
13 | var body: some View {
14 | VStack {
15 | Picker("What is your favorite color?", selection: $favoriteColor) {
16 | Text("Red").tag(0)
17 | Text("Green").tag(1)
18 | Text("Blue").tag(2)
19 | }
20 | .pickerStyle(.segmented)
21 |
22 | Text("Value: \(favoriteColor)")
23 | }
24 | }
25 | }
26 |
27 | #Preview {
28 | SegmentedControlView()
29 | }
30 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve Inpenso
4 | title: '[BUG] '
5 | labels: bug
6 | assignees: ''
7 | ---
8 |
9 | **Describe the bug**
10 | A clear and concise description of what the bug is.
11 |
12 | **To Reproduce**
13 | Steps to reproduce the behavior:
14 | 1. Go to '...'
15 | 2. Tap on '....'
16 | 3. Scroll down to '....'
17 | 4. See error
18 |
19 | **Expected behavior**
20 | A clear and concise description of what you expected to happen.
21 |
22 | **Screenshots**
23 | If applicable, add screenshots to help explain your problem.
24 |
25 | **Device Information (please complete the following information):**
26 | - Device: [e.g. iPhone 14 Pro]
27 | - OS: [e.g. iOS 16.5]
28 | - App Version: [e.g. 1.0.1]
29 |
30 | **Additional context**
31 | Add any other context about the problem here.
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for Inpenso
4 | title: '[FEATURE] '
5 | labels: enhancement
6 | assignees: ''
7 | ---
8 |
9 | **Is your feature request related to a problem? Please describe.**
10 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
11 |
12 | **Describe the solution you'd like**
13 | A clear and concise description of what you want to happen.
14 |
15 | **Describe alternatives you've considered**
16 | A clear and concise description of any alternative solutions or features you've considered.
17 |
18 | **How would this feature benefit most Inpenso users?**
19 | Explain why this would be valuable to implement for the broader user base.
20 |
21 | **Additional context**
22 | Add any other context or screenshots about the feature request here.
--------------------------------------------------------------------------------
/iExpense/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon~ios-marketing.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "appearances" : [
11 | {
12 | "appearance" : "luminosity",
13 | "value" : "dark"
14 | }
15 | ],
16 | "filename" : "AppIcon~ios-marketing 1.png",
17 | "idiom" : "universal",
18 | "platform" : "ios",
19 | "size" : "1024x1024"
20 | },
21 | {
22 | "appearances" : [
23 | {
24 | "appearance" : "luminosity",
25 | "value" : "tinted"
26 | }
27 | ],
28 | "filename" : "AppIcon~ios-marketing 2.png",
29 | "idiom" : "universal",
30 | "platform" : "ios",
31 | "size" : "1024x1024"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "filename" : "AppIcon~ios-marketing.png",
5 | "idiom" : "universal",
6 | "platform" : "ios",
7 | "size" : "1024x1024"
8 | },
9 | {
10 | "appearances" : [
11 | {
12 | "appearance" : "luminosity",
13 | "value" : "dark"
14 | }
15 | ],
16 | "filename" : "AppIcon~ios-marketing 1.png",
17 | "idiom" : "universal",
18 | "platform" : "ios",
19 | "size" : "1024x1024"
20 | },
21 | {
22 | "appearances" : [
23 | {
24 | "appearance" : "luminosity",
25 | "value" : "tinted"
26 | }
27 | ],
28 | "filename" : "AppIcon~ios-marketing 2.png",
29 | "idiom" : "universal",
30 | "platform" : "ios",
31 | "size" : "1024x1024"
32 | }
33 | ],
34 | "info" : {
35 | "author" : "xcode",
36 | "version" : 1
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Dragomir Mindrescu
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/AddQuickExpenseIntent.swift:
--------------------------------------------------------------------------------
1 | // AddQuickExpenseIntent.swift
2 | // iExpenseWidgetExtension
3 |
4 | import AppIntents
5 | import Foundation
6 |
7 | struct AddQuickExpenseIntent: AppIntent {
8 | static var title: LocalizedStringResource = "Add Quick Expense"
9 |
10 | @Parameter(title: "Title")
11 | var title: String
12 |
13 | @Parameter(title: "Price")
14 | var price: Double
15 |
16 | @Parameter(title: "Category")
17 | var category: String
18 |
19 | init() {}
20 |
21 | init(title: String, price: Double, category: String) {
22 | self.title = title
23 | self.price = price
24 | self.category = category
25 | }
26 |
27 | func perform() async throws -> some IntentResult {
28 | var expenses = StorageService.loadExpenses()
29 | let newExpense = Expense(
30 | title: title,
31 | price: price,
32 | date: Date(),
33 | category: Category(rawValue: category) ?? .others
34 | )
35 | expenses.append(newExpense)
36 | StorageService.saveExpenses(expenses)
37 |
38 | return .result()
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/xcuserdata/dragomirmindrescu.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | Inpenso.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 1
11 |
12 | InpensoWidgetExtension.xcscheme_^#shared#^_
13 |
14 | orderHint
15 | 2
16 |
17 | InpensoWidgetExtensionExtension.xcscheme_^#shared#^_
18 |
19 | orderHint
20 | 0
21 |
22 | iExpense.xcscheme_^#shared#^_
23 |
24 | orderHint
25 | 3
26 |
27 |
28 | SuppressBuildableAutocreation
29 |
30 | 714E82F22DBE3124005ED7BB
31 |
32 | primary
33 |
34 |
35 | 714E83162DBE3597005ED7BB
36 |
37 | primary
38 |
39 |
40 |
41 |
42 |
43 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/TabSelectionView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabSelectionView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Available analytics tabs
11 | enum AnalyticsTab: String, CaseIterable, Identifiable {
12 | case overview = "Overview"
13 | case trends = "Trends"
14 | case insights = "Insights"
15 | case budget = "Budget"
16 |
17 | var id: Self { self }
18 | }
19 |
20 | /// Reusable tab selector for analytics view
21 | struct AnalyticsTabSelector: View {
22 | @Binding var selectedTab: AnalyticsTab
23 |
24 | var body: some View {
25 | Picker("Select a tab", selection: $selectedTab) {
26 | ForEach(AnalyticsTab.allCases) { tab in
27 | Text(tab.rawValue)
28 | .tag(tab)
29 | }
30 | }
31 | .pickerStyle(.segmented)
32 | // .padding()
33 | }
34 | }
35 |
36 | #Preview(traits: .sizeThatFitsLayout) {
37 | VStack {
38 | AnalyticsTabSelector(selectedTab: .constant(.overview))
39 | AnalyticsTabSelector(selectedTab: .constant(.trends))
40 | AnalyticsTabSelector(selectedTab: .constant(.insights))
41 | AnalyticsTabSelector(selectedTab: .constant(.budget))
42 | }
43 | .padding()
44 | }
45 |
--------------------------------------------------------------------------------
/iExpense/ViewModels/ExpenseViewModel.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ExpenseViewModel.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftUI
10 |
11 | @MainActor
12 | class ExpenseViewModel: ObservableObject {
13 | @Published var expenses: [Expense] = []
14 |
15 | init() {
16 | loadExpenses()
17 | }
18 |
19 | func addExpense(title: String, price: Double, date: Date, category: Category) -> Expense {
20 | let newExpense = Expense(title: title, price: price, date: date, category: category)
21 | expenses.append(newExpense)
22 | saveExpenses()
23 | return newExpense
24 | }
25 |
26 | func deleteExpense(at offsets: IndexSet) {
27 | expenses.remove(atOffsets: offsets)
28 | saveExpenses()
29 | }
30 |
31 | func saveExpenses() {
32 | StorageService.saveExpenses(expenses)
33 | }
34 |
35 | func loadExpenses() {
36 | expenses = StorageService.loadExpenses()
37 | }
38 |
39 | func deleteExpenses(_ expenses: [Expense]) {
40 | for expense in expenses {
41 | if let index = self.expenses.firstIndex(where: { $0.id == expense.id }) {
42 | self.expenses.remove(at: index)
43 | }
44 | }
45 | saveExpenses()
46 | }
47 |
48 | }
49 |
--------------------------------------------------------------------------------
/iExpense/Components/HapticFeedback.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HapticFeedback.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Utility for providing haptic feedback
11 | enum HapticFeedback {
12 | /// Trigger impact haptic feedback
13 | static func impact(style: UIImpactFeedbackGenerator.FeedbackStyle = .light) {
14 | let generator = UIImpactFeedbackGenerator(style: style)
15 | generator.impactOccurred()
16 | }
17 |
18 | /// Trigger selection haptic feedback
19 | static func selection() {
20 | let generator = UISelectionFeedbackGenerator()
21 | generator.selectionChanged()
22 | }
23 |
24 | /// Trigger notification haptic feedback
25 | static func notification(type: UINotificationFeedbackGenerator.FeedbackType) {
26 | let generator = UINotificationFeedbackGenerator()
27 | generator.notificationOccurred(type)
28 | }
29 |
30 | /// Trigger success notification
31 | static func success() {
32 | notification(type: .success)
33 | }
34 |
35 | /// Trigger error notification
36 | static func error() {
37 | notification(type: .error)
38 | }
39 |
40 | /// Trigger warning notification
41 | static func warning() {
42 | notification(type: .warning)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/iExpense/SiriIntents/AddExpenseSiriIntent.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddExpenseSiriIntent.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import AppIntents
9 | import Foundation
10 |
11 | struct AddExpenseSiriIntent: AppIntent {
12 | static var title: LocalizedStringResource = "Add an Expense"
13 | static var description = IntentDescription("Quickly add a new expense to iExpense via Voice Control or Shortcuts.")
14 |
15 | static var openAppWhenRun: Bool = false
16 |
17 | static var dialog: IntentDialog {
18 | IntentDialog("Let's add a new expense.")
19 | }
20 |
21 | @Parameter(title: "Expense Name")
22 | var title: String
23 |
24 | @Parameter(title: "Expense Price")
25 | var price: Double
26 |
27 | @Parameter(title: "Expense Category")
28 | var category: Category
29 |
30 | static var parameterSummary: some ParameterSummary {
31 | Summary("What did you spend money on? \(\.$title), how much did you spend? \(\.$price), and what category does it belong to? \(\.$category)")
32 | }
33 |
34 | func perform() async throws -> some IntentResult {
35 | var expenses = StorageService.loadExpenses()
36 | let newExpense = Expense(
37 | title: title,
38 | price: price,
39 | date: Date(),
40 | category: category
41 | )
42 | expenses.append(newExpense)
43 | StorageService.saveExpenses(expenses)
44 |
45 | return .result()
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/.github/PULL_REQUEST_TEMPLATE/pull_request_template.md:
--------------------------------------------------------------------------------
1 | ## Description
2 |
3 |
4 | ## Related Issue
5 |
6 |
7 |
8 | ## Motivation and Context
9 |
10 |
11 |
12 | ## How Has This Been Tested?
13 |
14 |
15 |
16 | - [ ] Test A
17 | - [ ] Test B
18 |
19 | ## Screenshots (if appropriate):
20 |
21 | ## Types of changes
22 |
23 | - [ ] Bug fix (non-breaking change which fixes an issue)
24 | - [ ] New feature (non-breaking change which adds functionality)
25 | - [ ] Breaking change (fix or feature that would cause existing functionality to change)
26 |
27 | ## Checklist:
28 |
29 |
30 | - [ ] My code follows the code style of this project.
31 | - [ ] My change requires a change to the documentation.
32 | - [ ] I have updated the documentation accordingly.
33 | - [ ] I have added tests to cover my changes.
34 | - [ ] All new and existing tests passed.
--------------------------------------------------------------------------------
/iExpense/Models/SwiftDataModels.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataModels.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftData
10 |
11 | // MARK: - Models for SwiftData
12 |
13 | // This file contains the models needed for SwiftData
14 | // These are completely separate from the current app functionality
15 | // and will be gradually integrated
16 |
17 | @Model
18 | final class ExpenseItem {
19 | var id: String
20 | var name: String
21 | var amount: Double
22 | var date: Date
23 | var categoryName: String
24 | var notes: String?
25 |
26 | init(id: String = UUID().uuidString,
27 | name: String,
28 | amount: Double,
29 | date: Date,
30 | categoryName: String,
31 | notes: String? = nil) {
32 | self.id = id
33 | self.name = name
34 | self.amount = amount
35 | self.date = date
36 | self.categoryName = categoryName
37 | self.notes = notes
38 | }
39 | }
40 |
41 | @Model
42 | final class BudgetItem {
43 | @Attribute(.unique) var monthYear: String // Format: "MM-yyyy"
44 | var amount: Double
45 |
46 | init(monthYear: String, amount: Double) {
47 | self.monthYear = monthYear
48 | self.amount = amount
49 | }
50 |
51 | // Helper to create a monthYear string from Date
52 | static func monthYearString(from date: Date) -> String {
53 | let formatter = DateFormatter()
54 | formatter.dateFormat = "MM-yyyy"
55 | return formatter.string(from: date)
56 | }
57 | }
--------------------------------------------------------------------------------
/.github/assets/rounded-logo-template.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Create Rounded Logo
5 |
39 |
40 |
41 |
42 |

43 |
44 |
45 | How to use this template
46 |
47 | 1. Open this HTML file in a browser
48 | 2. Take a screenshot of the rounded logo
49 | 3. Crop the screenshot to just include the rounded logo
50 | 4. Save it as "inpenso-logo-rounded.png" in the .github/assets folder
51 | 5. Update the README.md to use this new rounded logo image
52 |
53 |
54 |
--------------------------------------------------------------------------------
/iExpense/ViewTests/MusicTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MusicTabView.swift
3 | // Inpenso
4 | //
5 | // Created by Dragomir Mindrescu on 14.10.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct MusicTabView: View {
11 | @Binding var searchText: String
12 |
13 | var body: some View {
14 | if #available(iOS 26.0, *) {
15 | TabView {
16 | Tab("Home", systemImage: "house") {
17 | Text("Home")
18 | }
19 | Tab("New", systemImage: "squareshape.split.2x2") {
20 | Text("New")
21 | }
22 | Tab("Radio", systemImage: "dot.radiowaves.left.and.right") {
23 | Text("Radio")
24 | }
25 | Tab("Library", systemImage: "music.note.tv") {
26 | Text("Library")
27 | }
28 | Tab("Search", systemImage: "magnifyingglass", role: .search) {
29 | NavigationStack {
30 |
31 | }
32 | }
33 | }.searchable(text: $searchText)
34 | .tabBarMinimizeBehavior(.onScrollDown)
35 | .tabViewBottomAccessory {
36 | HStack {
37 | Spacer().frame(width: 20)
38 | Image(systemName: "command.square")
39 | Text(".tabViewBottomAccessory")
40 | Spacer()
41 | Image(systemName: "play.fill")
42 | Image(systemName: "forward.fill")
43 | Spacer().frame(width: 20)
44 | }
45 | }
46 | } else {
47 | // Fallback on earlier versions
48 | }
49 | }
50 | }
51 |
52 | #Preview {
53 | MusicTabView(searchText: .constant("Test"))
54 | }
55 |
--------------------------------------------------------------------------------
/iExpense/iExpenseApp.swift:
--------------------------------------------------------------------------------
1 | //
2 | // iExpenseApp.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import Foundation
10 |
11 | @main
12 | struct iExpenseApp: App {
13 | init() {
14 | // On app launch, ensure settings are synced to the shared UserDefaults
15 | syncSettingsToSharedDefaults()
16 | }
17 |
18 | @StateObject private var settingsViewModel = SettingsViewModel()
19 |
20 | var body: some Scene {
21 | WindowGroup {
22 | MainTabView()
23 | // Use the optional SwiftData container that won't affect existing code
24 | .withSwiftData()
25 | // Perform the migration silently on app startup
26 | .task {
27 | await SwiftDataProvider.shared.startMigration()
28 | }
29 | .environmentObject(settingsViewModel)
30 | .preferredColorScheme(settingsViewModel.selectedTheme.colorScheme)
31 | }
32 | }
33 |
34 | // This function ensures that all settings needed by widgets are available in shared UserDefaults
35 | private func syncSettingsToSharedDefaults() {
36 | let sharedDefaults = UserDefaults(suiteName: StorageService.appGroupID)
37 |
38 | // Sync currency setting
39 | if let currency = UserDefaults.standard.string(forKey: "selectedCurrency") {
40 | sharedDefaults?.set(currency, forKey: "selectedCurrency")
41 | sharedDefaults?.synchronize()
42 | } else {
43 | // If no currency in standard defaults, set a default in both places
44 | let defaultCurrency = "USD"
45 | UserDefaults.standard.set(defaultCurrency, forKey: "selectedCurrency")
46 | sharedDefaults?.set(defaultCurrency, forKey: "selectedCurrency")
47 | sharedDefaults?.synchronize()
48 | }
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/iExpense/Components/IconUtils.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IconUtils.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Utility functions for working with category icons
11 | enum IconUtils {
12 | /// Returns the system icon name for a given category
13 | static func iconName(for category: Category) -> String {
14 | switch category {
15 | case .food:
16 | return "cart.fill"
17 | case .eatingOut:
18 | return "fork.knife"
19 | case .rent:
20 | return "house.fill"
21 | case .shopping:
22 | return "bag.fill"
23 | case .entertainment:
24 | return "tv.fill"
25 | case .transportation:
26 | return "car.fill"
27 | case .utilities:
28 | return "bolt.fill"
29 | case .subscriptions:
30 | return "repeat"
31 | case .healthcare:
32 | return "heart.fill"
33 | case .education:
34 | return "book.fill"
35 | case .others:
36 | return "ellipsis"
37 | }
38 | }
39 |
40 | /// Returns a styled category icon view with appropriate color and shape
41 | static func styledIcon(for category: Category, size: CGFloat = 24, padding: CGFloat = 8) -> some View {
42 | ZStack {
43 | Circle()
44 | .fill(category.color)
45 | .frame(width: size + padding * 2, height: size + padding * 2)
46 |
47 | Image(systemName: iconName(for: category))
48 | .font(.system(size: size))
49 | .foregroundColor(.white)
50 | }
51 | }
52 | }
53 |
54 | // Extension to use the icon methods directly on Category
55 | extension Category {
56 | var iconName: String {
57 | IconUtils.iconName(for: self)
58 | }
59 |
60 | func styledIcon(size: CGFloat = 24, padding: CGFloat = 8) -> some View {
61 | IconUtils.styledIcon(for: self, size: size, padding: padding)
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/iExpense/Services/SwiftDataServiceModels.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataServiceModels.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftData
10 |
11 | // This file provides access to the basic model types needed by SwiftDataService
12 | // It can be included in any target without causing redeclaration errors
13 |
14 | // MARK: - Type Aliases
15 |
16 | // Using typealiases to reference the SwiftData models
17 | // This prevents redeclaration errors with the original models
18 | private typealias SDExpenseItem = ExpenseItem
19 | private typealias SDBudgetItem = BudgetItem
20 |
21 | // MARK: - SwiftData Extensions
22 |
23 | // Add extensions to access the models safely
24 | extension ModelContext {
25 | // Safe wrapper to fetch budget items
26 | func fetchBudgetItems() throws -> [BudgetItem] {
27 | let descriptor = FetchDescriptor()
28 | return try fetch(descriptor)
29 | }
30 |
31 | // Safe wrapper to fetch expense items
32 | func fetchExpenseItems() throws -> [ExpenseItem] {
33 | let descriptor = FetchDescriptor()
34 | return try fetch(descriptor)
35 | }
36 |
37 | // Fetch budget for a specific month/year
38 | func fetchBudget(monthYear: String) throws -> BudgetItem? {
39 | let descriptor = FetchDescriptor(
40 | predicate: #Predicate { budget in
41 | budget.monthYear == monthYear
42 | }
43 | )
44 | return try fetch(descriptor).first
45 | }
46 |
47 | // Delete all expenses
48 | func deleteAllExpenses() throws {
49 | let items = try fetchExpenseItems()
50 | for item in items {
51 | delete(item)
52 | }
53 | try save()
54 | }
55 |
56 | // Delete all budgets
57 | func deleteAllBudgets() throws {
58 | let items = try fetchBudgetItems()
59 | for item in items {
60 | delete(item)
61 | }
62 | try save()
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/iExpense/SwiftDataContainer.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataContainer.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftData
10 | import SwiftUI
11 |
12 | // This class wraps the SwiftData container so it can be used selectively
13 | // without modifying the existing app structure yet
14 | class SwiftDataProvider {
15 |
16 | // Singleton instance
17 | static let shared = SwiftDataProvider()
18 |
19 | // The model container
20 | private(set) var container: ModelContainer?
21 |
22 | // Private initializer for singleton
23 | private init() {
24 | setupContainer()
25 | }
26 |
27 | // Setup the SwiftData container
28 | private func setupContainer() {
29 | do {
30 | container = try SwiftDataManager.shared.createContainer()
31 | } catch {
32 | // Silent failure - don't show errors in UI
33 | }
34 | }
35 |
36 | // Start the migration process
37 | @MainActor
38 | func startMigration() async {
39 | guard let container = container else { return }
40 |
41 | do {
42 | // Run migration silently (no UI indication)
43 | try await SwiftDataManager.shared.migrateData(using: container.mainContext, silent: true)
44 | } catch {
45 | // Silent failure - don't show errors in UI
46 | }
47 | }
48 | }
49 |
50 | // SwiftUI view modifier to add the container to the environment
51 | // without forcing its use throughout the app
52 | struct OptionalSwiftDataContainer: ViewModifier {
53 | func body(content: Content) -> some View {
54 | if let container = SwiftDataProvider.shared.container {
55 | content.modelContainer(container)
56 | } else {
57 | content
58 | }
59 | }
60 | }
61 |
62 | extension View {
63 | // Use this to optionally add SwiftData to a view
64 | func withSwiftData() -> some View {
65 | self.modifier(OptionalSwiftDataContainer())
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/iExpense/Views/MainTabView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MainTabView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import SwiftData
10 |
11 | struct MainTabView: View {
12 | @StateObject private var viewModel = ExpenseViewModel()
13 | @StateObject private var analyticsViewModel = AnalyticsViewModel(expenses: [])
14 | @EnvironmentObject private var settingsViewModel: SettingsViewModel
15 | @State private var selectedTab = 0
16 |
17 | var body: some View {
18 | NavigationView {
19 | TabView(selection: $selectedTab) {
20 | HomeView(viewModel: viewModel, analyticsViewModel: analyticsViewModel)
21 | .tabItem {
22 | Label("Home", systemImage: "house.fill")
23 | }
24 | .tag(0)
25 |
26 | AnalyticsView(analyticsViewModel: analyticsViewModel)
27 | .tabItem {
28 | Label("Analytics", systemImage: "chart.pie.fill")
29 | }
30 | .tag(1)
31 |
32 | ExpensesListView(viewModel: viewModel)
33 | .tabItem {
34 | Label("Expenses", systemImage: "list.bullet.rectangle.portrait.fill")
35 | }
36 | .tag(2)
37 |
38 | SettingsView()
39 | .tabItem {
40 | Label("Settings", systemImage: "gearshape.fill")
41 | }
42 | .tag(3)
43 | }
44 | }
45 | .preferredColorScheme(settingsViewModel.selectedTheme.colorScheme)
46 | .onAppear {
47 | analyticsViewModel.updateExpenses(viewModel.expenses)
48 |
49 | // Register for the notification to switch tabs
50 | NotificationCenter.default.addObserver(forName: NSNotification.Name("SwitchToExpensesTab"), object: nil, queue: .main) { _ in
51 | selectedTab = 2 // Switch to Expenses tab
52 | }
53 | }
54 | .onChange(of: viewModel.expenses) {
55 | analyticsViewModel.updateExpenses(viewModel.expenses)
56 | }
57 | }
58 | }
59 |
60 | #Preview {
61 | MainTabView()
62 | .environmentObject(SettingsViewModel())
63 | }
64 |
--------------------------------------------------------------------------------
/iExpense/Services/StorageService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StorageService.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 |
10 | struct StorageService {
11 | static let appGroupID = "group.com.vintuss.Inpenso"
12 |
13 | private static var userDefaults: UserDefaults? {
14 | UserDefaults(suiteName: appGroupID)
15 | }
16 |
17 | private static let expensesKey = "expenses"
18 | private static let budgetsKey = "budgets"
19 |
20 | static func saveExpenses(_ expenses: [Expense]) {
21 | guard let userDefaults = userDefaults else { return }
22 | do {
23 | let encoder = JSONEncoder()
24 | let data = try encoder.encode(expenses)
25 | userDefaults.set(data, forKey: expensesKey)
26 | } catch {
27 | // Error handling without print
28 | }
29 | }
30 |
31 | static func loadExpenses() -> [Expense] {
32 | guard let userDefaults = userDefaults,
33 | let data = userDefaults.data(forKey: expensesKey) else {
34 | return []
35 | }
36 | do {
37 | let decoder = JSONDecoder()
38 | let expenses = try decoder.decode([Expense].self, from: data)
39 | return expenses
40 | } catch {
41 | // Error handling without print
42 | return []
43 | }
44 | }
45 |
46 | static func saveBudgets(_ budgets: [String: Double]) {
47 | guard let userDefaults = userDefaults else { return }
48 | do {
49 | let data = try JSONEncoder().encode(budgets)
50 | userDefaults.set(data, forKey: budgetsKey)
51 | } catch {
52 | // Error handling without print
53 | }
54 | }
55 |
56 | static func loadBudgets() -> [String: Double] {
57 | guard let userDefaults = userDefaults,
58 | let data = userDefaults.data(forKey: budgetsKey) else {
59 | return [:]
60 | }
61 | do {
62 | let budgets = try JSONDecoder().decode([String: Double].self, from: data)
63 | return budgets
64 | } catch {
65 | // Error handling without print
66 | return [:]
67 | }
68 | }
69 |
70 | static func clearExpenses() {
71 | guard let userDefaults = userDefaults else { return }
72 | userDefaults.removeObject(forKey: expensesKey)
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/iExpense/Models/Category.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Category.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import AppIntents
10 | import SwiftUI
11 |
12 | enum Category: String, CaseIterable, Codable, AppEnum {
13 | case food
14 | case eatingOut
15 | case rent
16 | case shopping
17 | case entertainment
18 | case transportation
19 | case utilities
20 | case subscriptions
21 | case healthcare
22 | case education
23 | case others
24 |
25 | var displayName: String {
26 | switch self {
27 | case .food: return "Food"
28 | case .eatingOut: return "Eating Out"
29 | case .rent: return "Rent"
30 | case .shopping: return "Shopping"
31 | case .entertainment: return "Entertainment"
32 | case .transportation: return "Transportation"
33 | case .utilities: return "Utilities"
34 | case .subscriptions: return "Subscriptions"
35 | case .healthcare: return "Healthcare"
36 | case .education: return "Education"
37 | case .others: return "Others"
38 | }
39 | }
40 |
41 | static var typeDisplayRepresentation: TypeDisplayRepresentation = "Category"
42 |
43 | static var caseDisplayRepresentations: [Self: DisplayRepresentation] = [
44 | .food: "Food",
45 | .eatingOut: "Eating Out",
46 | .rent: "Rent",
47 | .shopping: "Shopping",
48 | .entertainment: "Entertainment",
49 | .transportation: "Transportation",
50 | .utilities: "Utilities",
51 | .subscriptions: "Subscriptions",
52 | .healthcare: "Healthcare",
53 | .education: "Education",
54 | .others: "Others"
55 | ]
56 | }
57 |
58 | extension Category {
59 | var color: Color {
60 | switch self {
61 | case .food:
62 | return .green
63 | case .eatingOut:
64 | return .mint
65 | case .rent:
66 | return .purple
67 | case .shopping:
68 | return .orange
69 | case .entertainment:
70 | return .pink
71 | case .transportation:
72 | return .blue
73 | case .utilities:
74 | return .yellow
75 | case .subscriptions:
76 | return .teal
77 | case .healthcare:
78 | return .red
79 | case .education:
80 | return .indigo
81 | case .others:
82 | return .gray
83 | }
84 | }
85 | }
86 |
87 |
--------------------------------------------------------------------------------
/iExpense/Components/CategoryButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryButton.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CategoryButton: View {
11 | let category: Category
12 | let isSelected: Bool
13 | let action: () -> Void
14 |
15 | var body: some View {
16 | Button(action: action) {
17 | VStack(spacing: 6) {
18 | // Icon with circle background
19 | ZStack {
20 | // Base shape
21 | Circle()
22 | .fill(category.color)
23 | .frame(width: 56, height: 56)
24 |
25 | // Icon
26 | Image(systemName: category.iconName)
27 | .font(.system(size: 22))
28 | .foregroundColor(.white)
29 |
30 | // Selection indicator
31 | if isSelected {
32 | Circle()
33 | .stroke(Color.white, lineWidth: 3)
34 | .frame(width: 56, height: 56)
35 | }
36 | }
37 | .shadow(color: isSelected ? category.color.opacity(0.6) : Color.clear, radius: isSelected ? 5 : 0)
38 |
39 | // Category name in fixed-height container
40 | Text(category.displayName)
41 | .font(.caption)
42 | .fontWeight(isSelected ? .bold : .medium)
43 | .foregroundColor(isSelected ? .primary : .secondary)
44 | .multilineTextAlignment(.center)
45 | .fixedSize(horizontal: false, vertical: true)
46 | .lineLimit(2)
47 | .frame(height: 32)
48 | .minimumScaleFactor(0.8)
49 | }
50 | .frame(width: 80)
51 | .scaleEffect(isSelected ? 1.05 : 1.0)
52 | .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isSelected)
53 | }
54 | .buttonStyle(PlainButtonStyle())
55 | }
56 | }
57 |
58 | #Preview(traits: .sizeThatFitsLayout) {
59 | HStack(spacing: 20) {
60 | CategoryButton(
61 | category: .food,
62 | isSelected: true,
63 | action: {}
64 | )
65 | CategoryButton(
66 | category: .transportation,
67 | isSelected: false,
68 | action: {}
69 | )
70 | }
71 | .padding()
72 | }
73 |
--------------------------------------------------------------------------------
/iExpense/Components/CategoryGrid.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryGrid.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct CategoryGrid: View {
11 | @Binding var selectedCategory: Category
12 | var onCategorySelected: (() -> Void)? = nil
13 |
14 | // Number of columns in the grid
15 | private let columns = [
16 | GridItem(.flexible()),
17 | GridItem(.flexible()),
18 | GridItem(.flexible())
19 | ]
20 |
21 | var body: some View {
22 | LazyVGrid(columns: columns, spacing: 15) {
23 | ForEach(Category.allCases, id: \.self) { category in
24 | CategoryButton(
25 | category: category,
26 | isSelected: selectedCategory == category,
27 | action: {
28 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
29 | selectedCategory = category
30 | }
31 | HapticFeedback.impact()
32 | onCategorySelected?()
33 | }
34 | )
35 | }
36 | }
37 | .padding(.vertical, 10)
38 | }
39 | }
40 |
41 | // An alternative version with a manual callback for cases where binding isn't appropriate
42 | struct CategoryGridWithCallback: View {
43 | let selectedCategory: Category
44 | let onCategorySelected: (Category) -> Void
45 |
46 | // Number of columns in the grid
47 | private let columns = [
48 | GridItem(.flexible()),
49 | GridItem(.flexible()),
50 | GridItem(.flexible())
51 | ]
52 |
53 | var body: some View {
54 | LazyVGrid(columns: columns, spacing: 15) {
55 | ForEach(Category.allCases, id: \.self) { category in
56 | CategoryButton(
57 | category: category,
58 | isSelected: selectedCategory == category,
59 | action: {
60 | HapticFeedback.impact()
61 | onCategorySelected(category)
62 | }
63 | )
64 | }
65 | }
66 | .padding(.vertical, 10)
67 | }
68 | }
69 |
70 | #Preview {
71 | VStack {
72 | Text("Category Grid Preview")
73 | .font(.headline)
74 | .padding()
75 |
76 | CategoryGrid(selectedCategory: .constant(.food))
77 | .padding()
78 | .background(Color(.secondarySystemBackground))
79 | .cornerRadius(12)
80 | .padding()
81 | }
82 | }
83 |
--------------------------------------------------------------------------------
/iExpense/Components/CardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CardView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// A standard card view with consistent styling
11 | struct CardView: View {
12 | let title: String
13 | let content: Content
14 | var titleAlignment: HorizontalAlignment = .leading
15 | var showDivider: Bool = false
16 |
17 | init(
18 | title: String,
19 | titleAlignment: HorizontalAlignment = .leading,
20 | showDivider: Bool = false,
21 | @ViewBuilder content: () -> Content
22 | ) {
23 | self.title = title
24 | self.titleAlignment = titleAlignment
25 | self.showDivider = showDivider
26 | self.content = content()
27 | }
28 |
29 | var body: some View {
30 | VStack(alignment: titleAlignment, spacing: 12) {
31 | // Card title
32 | Text(title)
33 | .font(.headline)
34 | .padding(.horizontal)
35 |
36 | if showDivider {
37 | Divider()
38 | .padding(.horizontal)
39 | }
40 |
41 | // Card content
42 | content
43 | }
44 | .padding(.vertical, 12)
45 | .background(
46 | RoundedRectangle(cornerRadius: 16)
47 | .fill(Color(.secondarySystemBackground))
48 | )
49 | }
50 | }
51 |
52 | /// A standard section header text
53 | struct SectionHeaderText: View {
54 | let text: String
55 |
56 | var body: some View {
57 | Text(text)
58 | .font(.headline)
59 | .foregroundColor(.secondary)
60 | }
61 | }
62 |
63 | #Preview {
64 | VStack(spacing: 20) {
65 | CardView(title: "Basic Card") {
66 | Text("This is the content of the card")
67 | .padding()
68 | }
69 |
70 | CardView(title: "Card with Divider", showDivider: true) {
71 | VStack {
72 | Text("Above the divider")
73 | Text("Below the divider")
74 | }
75 | .padding()
76 | }
77 |
78 | CardView(title: "Card with List Content") {
79 | VStack(alignment: .leading, spacing: 10) {
80 | ForEach(1..<4) { i in
81 | HStack {
82 | Circle()
83 | .fill(Color.blue)
84 | .frame(width: 10, height: 10)
85 | Text("Item \(i)")
86 | }
87 | }
88 | }
89 | .padding()
90 | }
91 | }
92 | .padding()
93 | }
94 |
--------------------------------------------------------------------------------
/iExpense/Services/SwiftDataService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataService.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftData
10 | import SwiftUI
11 |
12 | // Local reference to resolve module issues
13 | // Using the locally defined extension methods to avoid explicit type dependencies
14 |
15 | /// Service for accessing SwiftData from Intents and Widgets
16 | /// This provides access to SwiftData operations when a view context isn't available
17 | @MainActor
18 | struct SwiftDataService {
19 | static func saveExpense(title: String, price: Double, date: Date, category: Category) {
20 | Task { @MainActor in
21 | guard let container = try? SwiftDataManager.shared.createContainer() else {
22 | print("Failed to create container for quick expense")
23 | return
24 | }
25 |
26 | let context = container.mainContext
27 |
28 | // Create the regular Expense model for UserDefaults
29 | let expense = Expense(title: title, price: price, date: date, category: category)
30 |
31 | // Let SwiftDataManager handle SwiftData operations
32 | try? SwiftDataManager.shared.saveExpense(expense, using: context)
33 |
34 | // Also save to UserDefaults for backward compatibility
35 | var expenses = StorageService.loadExpenses()
36 | expenses.append(expense)
37 | StorageService.saveExpenses(expenses)
38 | }
39 | }
40 |
41 | static func clearAllData() {
42 | Task { @MainActor in
43 | guard let container = try? SwiftDataManager.shared.createContainer() else {
44 | print("Failed to create container for clearing data")
45 | return
46 | }
47 |
48 | let context = container.mainContext
49 |
50 | do {
51 | // Delete all expenses
52 | let expenseDescriptor = FetchDescriptor()
53 | let expenses = try context.fetch(expenseDescriptor)
54 | for expense in expenses {
55 | context.delete(expense)
56 | }
57 |
58 | // Delete all budgets
59 | let budgetDescriptor = FetchDescriptor()
60 | let budgets = try context.fetch(budgetDescriptor)
61 | for budget in budgets {
62 | context.delete(budget)
63 | }
64 |
65 | try context.save()
66 |
67 | // Also clear UserDefaults for backward compatibility
68 | StorageService.clearExpenses()
69 | StorageService.saveBudgets([:])
70 | } catch {
71 | print("Failed to clear data: \(error)")
72 | }
73 | }
74 | }
75 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Inpenso
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | A modern, intuitive expense tracker for iOS that helps you monitor spending, set budgets, and visualize your financial habits through beautiful analytics.
10 |
11 | ## Features
12 |
13 | - 📊 **Smart Analytics**: Visualize your spending patterns by category, month, and trends
14 | - 💰 **Budget Management**: Set monthly budgets and track your progress
15 | - 🔔 **Home Screen Widgets**: Monitor your expenses directly from your home screen
16 | - 📱 **iOS Integration**: Clean SwiftUI design that follows Apple's HIG
17 | - 🔄 **Siri Shortcuts**: Add expenses quickly with voice commands
18 | - 🔒 **Privacy-Focused**: All your data stays on your device
19 |
20 | ## Screenshots
21 |
22 |
23 |
24 |  |
25 |  |
26 |  |
27 |
28 |
29 |
30 | ## Requirements
31 |
32 | - iOS 16.0+
33 | - Xcode 15.0+
34 | - Swift 5.9+
35 |
36 | ## Installation
37 |
38 | 1. Clone the repository:
39 | ```bash
40 | git clone https://github.com/VintusS/Inpenso.git
41 | ```
42 |
43 | 2. Open `Inpenso.xcodeproj` in Xcode.
44 |
45 | 3. Build and run the app on your iOS device or simulator.
46 |
47 | ## Architecture
48 |
49 | Inpenso follows the MVVM (Model-View-ViewModel) architecture pattern:
50 |
51 | - **Models**: Data structures representing expenses, categories, and budgets
52 | - **Views**: SwiftUI views for user interface
53 | - **ViewModels**: Business logic that connects models to views
54 | - **Services**: Handles data persistence and shared functionalities
55 |
56 | ## Contributing
57 |
58 | Contributions are welcome! If you'd like to contribute, please follow these steps:
59 |
60 | 1. Fork the repository
61 | 2. Create your feature branch (`git checkout -b feature/amazing-feature`)
62 | 3. Commit your changes (`git commit -m 'Add some amazing feature'`)
63 | 4. Push to the branch (`git push origin feature/amazing-feature`)
64 | 5. Open a Pull Request
65 |
66 | Please read the [CONTRIBUTING.md](CONTRIBUTING.md) for details on our code of conduct and the process for submitting pull requests.
67 |
68 | ## License
69 |
70 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
71 |
72 | ## Acknowledgments
73 |
74 | - Thanks to all contributors who have helped shape Inpenso
75 | - Icons provided by SF Symbols
76 | - Inspiration from other personal finance apps
77 |
78 | ## Contact
79 |
80 | Dragomir Mindrescu - [@VintusS](https://github.com/VintusS)
81 |
82 | Project Link: [https://github.com/VintusS/Inpenso](https://github.com/VintusS/Inpenso)
83 |
84 | ## Topics
85 |
86 | ios swift swiftui expense-tracker personal-finance budget-app analytics data-visualization mobile-app productivity tools
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/xcshareddata/xcschemes/Inpenso.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/xcshareddata/xcschemes/iExpense.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
9 |
10 |
16 |
22 |
23 |
24 |
25 |
26 |
32 |
33 |
43 |
45 |
51 |
52 |
53 |
54 |
60 |
62 |
68 |
69 |
70 |
71 |
73 |
74 |
77 |
78 |
79 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/DailySpendingChartView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DailySpendingChartView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import Charts
10 |
11 | /// A chart that displays daily spending data
12 | struct DailySpendingChartView: View {
13 | struct DailySpending: Identifiable {
14 | var id: Int { dayOfMonth }
15 | let date: Date
16 | let dayOfMonth: Int
17 | let amount: Double
18 | }
19 |
20 | let dailySpending: [DailySpending]
21 | let averageDailySpend: Double
22 |
23 | var body: some View {
24 | VStack(alignment: .leading, spacing: 8) {
25 | Text("Daily Spending")
26 | .font(.headline)
27 |
28 | if dailySpending.isEmpty {
29 | Text("No data available")
30 | .foregroundColor(.secondary)
31 | .padding()
32 | .frame(maxWidth: .infinity, alignment: .center)
33 | } else {
34 | Chart {
35 | ForEach(dailySpending) { daily in
36 | BarMark(
37 | x: .value("Day", daily.dayOfMonth),
38 | y: .value("Amount", daily.amount)
39 | )
40 | .foregroundStyle(Color.blue.gradient)
41 | .cornerRadius(4)
42 | }
43 |
44 | if averageDailySpend > 0 {
45 | RuleMark(
46 | y: .value("Average", averageDailySpend)
47 | )
48 | .lineStyle(StrokeStyle(lineWidth: 1, dash: [5, 5]))
49 | .foregroundStyle(Color.green)
50 | .annotation(position: .top, alignment: .trailing) {
51 | Text("Avg")
52 | .font(.caption)
53 | .foregroundColor(.green)
54 | .padding(4)
55 | .background(Color(.secondarySystemBackground))
56 | .cornerRadius(4)
57 | }
58 | }
59 | }
60 | .chartXAxis {
61 | AxisMarks(values: .stride(by: 5)) { value in
62 | AxisGridLine()
63 | AxisValueLabel()
64 | }
65 | }
66 | .chartYAxis {
67 | AxisMarks(position: .leading)
68 | }
69 | }
70 | }
71 | .padding()
72 | .background(
73 | RoundedRectangle(cornerRadius: 12)
74 | .fill(Color(.secondarySystemBackground))
75 | )
76 | }
77 | }
78 |
79 | #Preview {
80 | let sampleData = (1...28).map { day in
81 | DailySpendingChartView.DailySpending(
82 | date: Calendar.current.date(from: DateComponents(year: 2025, month: 5, day: day)) ?? Date(),
83 | dayOfMonth: day,
84 | amount: Double.random(in: 0...100)
85 | )
86 | }
87 |
88 | DailySpendingChartView(
89 | dailySpending: sampleData,
90 | averageDailySpend: sampleData.reduce(0) { $0 + $1.amount } / Double(sampleData.count)
91 | )
92 | .frame(height: 220)
93 | .padding()
94 | }
95 |
--------------------------------------------------------------------------------
/iExpense/ViewTests/TestButton.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TestButton.swift
3 | // Inpenso
4 | //
5 | // Created by Dragomir Mindrescu on 16.10.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct TestButton: View {
11 | var body: some View {
12 | Button(action: {
13 | print("hello")
14 | }) {
15 | Text("press")
16 | }
17 |
18 | Button("Hello") {
19 | print("test")
20 | }
21 | .font(.headline)
22 | .foregroundColor(.white)
23 | .padding()
24 | .background(Color.blue)
25 | .cornerRadius(10)
26 | // .disabled(true)
27 |
28 | if #available(iOS 26.0, *) {
29 | Button("here"){}
30 | .buttonStyle(.glass)
31 | } else {
32 | // Fallback on earlier versions
33 | }
34 | if #available(iOS 26.0, *) {
35 | Button("here"){}
36 | .buttonStyle(.glass)
37 |
38 | } else {
39 | // Fallback on earlier versions
40 | }
41 |
42 | if #available(iOS 26.0, *) {
43 | Group {
44 | Link(
45 | "App Designer2",
46 | destination: URL(string: "https://reddit.com/u/App-Designer2")!
47 | )
48 |
49 | Button("Tap Me", action: {})
50 | .buttonStyle(.glass)
51 | }
52 | .padding()
53 | } else {
54 | // Fallback on earlier versions
55 | }
56 |
57 | Button(action: {
58 | print("test")
59 | }) {
60 | if #available(iOS 26.0, *) {
61 | Text("Hello, World!")
62 | .font(.title)
63 | .padding()
64 | .glassEffect(.regular.tint(.orange).interactive())
65 | }
66 | }
67 |
68 | if #available(iOS 26.0, *) {
69 | Button(action: {
70 | // Use NotificationCenter to notify MainTabView to switch to expenses tab
71 | NotificationCenter.default.post(name: NSNotification.Name("SwitchToExpensesTab"), object: nil)
72 | }) {
73 | Text("View All Expenses")
74 | .font(.subheadline)
75 | .fontWeight(.medium)
76 | .foregroundColor(.accentColor)
77 | .frame(maxWidth: .infinity)
78 | .padding(.vertical, 12)
79 | .padding(.top, 8)
80 | }
81 | .buttonStyle(.glass)
82 | } else {
83 | Button(action: {
84 | // Use NotificationCenter to notify MainTabView to switch to expenses tab
85 | NotificationCenter.default.post(name: NSNotification.Name("SwitchToExpensesTab"), object: nil)
86 | }) {
87 | Text("View All Expenses")
88 | .font(.subheadline)
89 | .fontWeight(.medium)
90 | .foregroundColor(.accentColor)
91 | .frame(maxWidth: .infinity)
92 | .padding(.vertical, 12)
93 | .background(
94 | RoundedRectangle(cornerRadius: 12)
95 | .stroke(Color.accentColor, lineWidth: 1.5)
96 | )
97 | .padding(.top, 8)
98 | }
99 | }
100 |
101 | Button(action: {
102 | NotificationCenter.default.post(name: NSNotification.Name("SwitchToExpensesTab"), object: nil)
103 | }) {
104 | Text("View All Expenses")
105 | .font(.subheadline)
106 | .fontWeight(.medium)
107 | .foregroundColor(.accentColor)
108 | .frame(maxWidth: .infinity)
109 | .padding(.vertical, 12)
110 | .padding(.top, 8)
111 | }
112 |
113 | }
114 | }
115 |
116 | #Preview {
117 | TestButton()
118 | }
119 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/CategoryBreakdownView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CategoryBreakdownView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import Charts
10 |
11 | /// A component that displays spending breakdown by category
12 | struct CategoryBreakdownView: View {
13 | let spendingByCategory: [Category: Double]
14 | let totalSpent: Double
15 | let currencyCode: String
16 |
17 | init(
18 | spendingByCategory: [Category: Double],
19 | totalSpent: Double,
20 | currencyCode: String? = nil
21 | ) {
22 | self.spendingByCategory = spendingByCategory
23 | self.totalSpent = totalSpent
24 | self.currencyCode = currencyCode ?? SettingsViewModel.getAppCurrency()
25 | }
26 |
27 | var body: some View {
28 | VStack(alignment: .leading, spacing: 8) {
29 | Text("Spending by Category")
30 | .font(.headline)
31 |
32 | if spendingByCategory.isEmpty {
33 | Text("No data available")
34 | .foregroundColor(.secondary)
35 | .padding()
36 | .frame(maxWidth: .infinity, alignment: .center)
37 | } else {
38 | VStack {
39 | // Pie chart
40 | Chart {
41 | ForEach(spendingByCategory.sorted(by: { $0.value > $1.value }), id: \.key) { category, amount in
42 | SectorMark(
43 | angle: .value("Amount", amount),
44 | innerRadius: .ratio(0.6),
45 | angularInset: 1.5
46 | )
47 | .foregroundStyle(category.color)
48 | .cornerRadius(5)
49 | }
50 | }
51 | .frame(height: 200)
52 |
53 | // Category legend
54 | VStack(spacing: 8) {
55 | ForEach(spendingByCategory.sorted(by: { $0.value > $1.value }), id: \.key) { category, amount in
56 | categoryRow(category: category, amount: amount)
57 | }
58 | }
59 | .padding(.top, 8)
60 | }
61 | }
62 | }
63 | .padding()
64 | .background(
65 | RoundedRectangle(cornerRadius: 12)
66 | .fill(Color(.secondarySystemBackground))
67 | )
68 | }
69 |
70 | private func categoryRow(category: Category, amount: Double) -> some View {
71 | HStack {
72 | // Color indicator
73 | Circle()
74 | .fill(category.color)
75 | .frame(width: 12, height: 12)
76 |
77 | // Category name
78 | Text(category.displayName)
79 | .font(.subheadline)
80 |
81 | Spacer()
82 |
83 | // Category amount and percentage
84 | if totalSpent > 0 {
85 | VStack(alignment: .trailing) {
86 | Text(amount, format: .currency(code: currencyCode))
87 | .font(.subheadline)
88 |
89 | Text("\(Int((amount / totalSpent) * 100))%")
90 | .font(.caption)
91 | .foregroundColor(.secondary)
92 | }
93 | } else {
94 | Text(amount, format: .currency(code: currencyCode))
95 | .font(.subheadline)
96 | }
97 | }
98 | }
99 | }
100 |
101 | #Preview {
102 | let sampleData: [Category: Double] = [
103 | .food: 450.50,
104 | .transportation: 220.75,
105 | .rent: 1200.00,
106 | .entertainment: 180.25,
107 | .utilities: 310.80
108 | ]
109 |
110 | CategoryBreakdownView(
111 | spendingByCategory: sampleData,
112 | totalSpent: sampleData.values.reduce(0, +)
113 | )
114 | .padding()
115 | }
116 |
--------------------------------------------------------------------------------
/iExpense/Components/DatePickerCard.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DatePickerCard.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// An expandable date picker with toggle functionality
11 | struct DatePickerCard: View {
12 | let title: String
13 | @Binding var selectedDate: Date
14 | @Binding var isExpanded: Bool
15 | var maxDate: Date = Date()
16 | var dateRange: ClosedRange? = nil
17 |
18 | var body: some View {
19 | VStack(alignment: .leading, spacing: 8) {
20 | Text(title)
21 | .font(.headline)
22 | .padding(.horizontal)
23 |
24 | VStack(spacing: 0) {
25 | // Date display button
26 | Button(action: {
27 | withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) {
28 | isExpanded.toggle()
29 | }
30 | }) {
31 | HStack {
32 | Image(systemName: "calendar")
33 | .foregroundColor(.accentColor)
34 |
35 | Text(formattedDate())
36 | .font(.headline)
37 |
38 | Spacer()
39 |
40 | Image(systemName: "chevron.down")
41 | .foregroundColor(.secondary)
42 | .rotationEffect(Angle(degrees: isExpanded ? 180 : 0))
43 | }
44 | .padding()
45 | .background(Color(.tertiarySystemBackground))
46 | .cornerRadius(10)
47 | .padding(.horizontal)
48 | }
49 |
50 | // Reserved space for date picker with clipping
51 | ZStack(alignment: .top) {
52 | // Empty container for space
53 | Color.clear
54 | .frame(height: isExpanded ? (UIDevice.current.userInterfaceIdiom == .pad ? 400 : 300) : 0)
55 |
56 | // Date picker
57 | Group {
58 | if let range = dateRange {
59 | DatePicker("", selection: $selectedDate, in: range, displayedComponents: .date)
60 | .datePickerStyle(GraphicalDatePickerStyle())
61 | .labelsHidden()
62 | .padding(.horizontal)
63 | .onChange(of: selectedDate) {
64 | HapticFeedback.selection()
65 | }
66 | } else {
67 | DatePicker("", selection: $selectedDate, in: ...maxDate, displayedComponents: .date)
68 | .datePickerStyle(GraphicalDatePickerStyle())
69 | .labelsHidden()
70 | .padding(.horizontal)
71 | .onChange(of: selectedDate) {
72 | HapticFeedback.selection()
73 | }
74 | }
75 | }
76 | .opacity(isExpanded ? 1 : 0)
77 | .frame(height: isExpanded ? nil : 0, alignment: .top)
78 | }
79 | .clipped() // Clip content when collapsing
80 | }
81 | .padding(.bottom, 12)
82 | }
83 | .padding(.vertical, 8)
84 | .background(
85 | RoundedRectangle(cornerRadius: 16)
86 | .fill(Color(.secondarySystemBackground))
87 | )
88 | }
89 |
90 | private func formattedDate() -> String {
91 | let formatter = DateFormatter()
92 | formatter.dateStyle = .medium
93 | return formatter.string(from: selectedDate)
94 | }
95 | }
96 |
97 | #Preview {
98 | VStack(spacing: 20) {
99 | DatePickerCard(
100 | title: "Date",
101 | selectedDate: .constant(Date()),
102 | isExpanded: .constant(false)
103 | )
104 |
105 | DatePickerCard(
106 | title: "Date (Expanded)",
107 | selectedDate: .constant(Date()),
108 | isExpanded: .constant(true)
109 | )
110 | }
111 | .padding()
112 | }
113 |
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/xcshareddata/xcschemes/InpensoWidgetExtension.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
60 |
62 |
68 |
69 |
70 |
71 |
75 |
76 |
80 |
81 |
85 |
86 |
87 |
88 |
96 |
98 |
104 |
105 |
106 |
107 |
109 |
110 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/Inpenso.xcodeproj/xcshareddata/xcschemes/InpensoWidgetExtensionExtension.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
6 |
10 |
11 |
17 |
23 |
24 |
25 |
31 |
37 |
38 |
39 |
40 |
41 |
47 |
48 |
60 |
62 |
68 |
69 |
70 |
71 |
75 |
76 |
80 |
81 |
85 |
86 |
87 |
88 |
96 |
98 |
104 |
105 |
106 |
107 |
109 |
110 |
113 |
114 |
115 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/SummaryCardView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SummaryCardView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Formats for summary card values
11 | enum SummaryValueFormat {
12 | case currency
13 | case percent
14 | case days
15 | case count
16 | case noBudget
17 | case custom(formatter: (Double) -> String)
18 | }
19 |
20 | /// A card that displays a summary value with a title and icon
21 | struct SummaryCard: View {
22 | let title: String
23 | let value: Double
24 | let valueFormat: SummaryValueFormat
25 | let icon: String
26 | var color: Color = .blue
27 | var currencyCode: String
28 |
29 | init(
30 | title: String,
31 | value: Double,
32 | valueFormat: SummaryValueFormat,
33 | icon: String,
34 | color: Color = .blue,
35 | currencyCode: String? = nil
36 | ) {
37 | self.title = title
38 | self.value = value
39 | self.valueFormat = valueFormat
40 | self.icon = icon
41 | self.color = color
42 | self.currencyCode = currencyCode ?? SettingsViewModel.getAppCurrency()
43 | }
44 |
45 | var body: some View {
46 | VStack(alignment: .leading, spacing: 10) {
47 | // Title with icon
48 | HStack(alignment: .top, spacing: 6) {
49 | Image(systemName: icon)
50 | .foregroundColor(color)
51 |
52 | Text(title)
53 | .font(.subheadline)
54 | .foregroundColor(.secondary)
55 | .multilineTextAlignment(.leading)
56 | .lineLimit(2)
57 | .minimumScaleFactor(0.8)
58 | }
59 |
60 | Spacer()
61 |
62 | // Value display based on format
63 | formattedValue
64 | .font(.title3)
65 | .fontWeight(.bold)
66 | .lineLimit(1)
67 | .minimumScaleFactor(0.8)
68 | }
69 | .padding()
70 | .frame(height: 110)
71 | .frame(maxWidth: .infinity)
72 | .background(
73 | RoundedRectangle(cornerRadius: 12)
74 | .fill(Color(.secondarySystemBackground))
75 | )
76 | }
77 |
78 | private var formattedValue: some View {
79 | Group {
80 | switch valueFormat {
81 | case .currency:
82 | Text(value, format: .currency(code: currencyCode))
83 | case .percent:
84 | Text("\(Int(value))%")
85 | .foregroundColor(value >= 90 ? .red : (value >= 75 ? .orange : .primary))
86 | case .days:
87 | Text("\(Int(value)) days")
88 | case .count:
89 | Text("\(Int(value))")
90 | case .noBudget:
91 | Text("Not Set")
92 | .foregroundColor(.gray)
93 | case .custom(let formatter):
94 | Text(formatter(value))
95 | }
96 | }
97 | }
98 | }
99 |
100 | /// A grid of summary cards
101 | struct SummaryCardGrid: View {
102 | var summaryCards: [SummaryCard]
103 | var columns: Int = 2
104 |
105 | var body: some View {
106 | let gridItems = Array(repeating: GridItem(.flexible(), spacing: 12), count: columns)
107 |
108 | LazyVGrid(columns: gridItems, spacing: 12) {
109 | ForEach(0..
2 |
6 |
7 |
9 |
21 |
22 |
23 |
25 |
37 |
38 |
39 |
41 |
53 |
54 |
55 |
57 |
69 |
70 |
71 |
73 |
85 |
86 |
87 |
89 |
96 |
97 |
98 |
99 |
100 |
102 |
114 |
115 |
116 |
117 |
118 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to Inpenso
2 |
3 | First off, thank you for considering contributing to Inpenso! It's people like you that make Inpenso such a great tool.
4 |
5 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project. In return, they should reciprocate that respect in addressing your issue, assessing changes, and helping you finalize your pull requests.
6 |
7 | ## Code of Conduct
8 |
9 | This project and everyone participating in it is governed by the Inpenso Code of Conduct. By participating, you are expected to uphold this code. Please report unacceptable behavior to [dmindrescu03@gmail.com].
10 |
11 | ## How Can I Contribute?
12 |
13 | ### Reporting Bugs
14 |
15 | This section guides you through submitting a bug report for Inpenso. Following these guidelines helps maintainers and the community understand your report, reproduce the behavior, and find related reports.
16 |
17 | **Before Submitting A Bug Report:**
18 | - Check the debugging guide for tips — you might be able to find the cause of the problem and fix things yourself.
19 | - Check if you can reproduce the problem in the latest version of Inpenso.
20 | - Perform a cursory search to see if the problem has already been reported. If it has and the issue is still open, add a comment to the existing issue instead of opening a new one.
21 |
22 | **How Do I Submit A Bug Report?**
23 | Bugs are tracked as GitHub issues. Create an issue and provide the following information:
24 |
25 | - Use a clear and descriptive title for the issue to identify the problem.
26 | - Describe the exact steps which reproduce the problem in as many details as possible.
27 | - Provide specific examples to demonstrate the steps.
28 | - Describe the behavior you observed after following the steps and point out what exactly is the problem with that behavior.
29 | - Explain which behavior you expected to see instead and why.
30 | - Include screenshots if possible.
31 | - Include details about your configuration: iOS version, device model, etc.
32 |
33 | ### Suggesting Enhancements
34 |
35 | This section guides you through submitting an enhancement suggestion for Inpenso, including completely new features and minor improvements to existing functionality.
36 |
37 | **Before Submitting An Enhancement Suggestion:**
38 | - Check if you're using the latest version of Inpenso.
39 | - Perform a search to see if the enhancement has already been suggested. If it has, add a comment to the existing issue instead of opening a new one.
40 |
41 | **How Do I Submit An Enhancement Suggestion?**
42 | Enhancement suggestions are tracked as GitHub issues. Create an issue and provide the following information:
43 |
44 | - Use a clear and descriptive title for the issue to identify the suggestion.
45 | - Provide a step-by-step description of the suggested enhancement in as many details as possible.
46 | - Provide specific examples to demonstrate the steps or point out the part of Inpenso which the suggestion relates to.
47 | - Describe the current behavior and explain which behavior you expected to see instead and why.
48 | - Include screenshots or screen recordings which help you demonstrate the steps or point out the part of Inpenso which the suggestion relates to.
49 | - Explain why this enhancement would be useful to most Inpenso users.
50 |
51 | ### Pull Requests
52 |
53 | - Fill in the required template
54 | - Do not include issue numbers in the PR title
55 | - Include screenshots and animated GIFs in your pull request whenever possible
56 | - Follow the Swift style guide
57 | - Include tests if applicable
58 | - Document new code based on the rest of the codebase
59 | - End all files with a newline
60 |
61 | ## Styleguides
62 |
63 | ### Git Commit Messages
64 |
65 | - Use the present tense ("Add feature" not "Added feature")
66 | - Use the imperative mood ("Move cursor to..." not "Moves cursor to...")
67 | - Limit the first line to 72 characters or less
68 | - Reference issues and pull requests liberally after the first line
69 | - Consider starting the commit message with an applicable emoji:
70 | - 🎨 `:art:` when improving the format/structure of the code
71 | - 🐎 `:racehorse:` when improving performance
72 | - 🚱 `:non-potable_water:` when plugging memory leaks
73 | - 📝 `:memo:` when writing docs
74 | - 🐛 `:bug:` when fixing a bug
75 | - 🔥 `:fire:` when removing code or files
76 | - ✅ `:white_check_mark:` when adding tests
77 | - 🔒 `:lock:` when dealing with security
78 | - ⬆️ `:arrow_up:` when upgrading dependencies
79 | - ⬇️ `:arrow_down:` when downgrading dependencies
80 |
81 | ### Swift Styleguide
82 |
83 | All Swift code should adhere to the [Swift API Design Guidelines](https://swift.org/documentation/api-design-guidelines/), [Swift Style Guide](https://google.github.io/swift/), and the style of the existing codebase.
84 |
85 | ## Additional Notes
86 |
87 | ### Issue and Pull Request Labels
88 |
89 | This section lists the labels we use to help us track and manage issues and pull requests.
90 |
91 | * **bug** - Issues that are bugs
92 | * **documentation** - Issues or PRs related to documentation
93 | * **enhancement** - Issues or PRs that are improvements
94 | * **good-first-issue** - Good for newcomers
95 | * **help-wanted** - We need help with this!
96 | * **wip** - Work in progress
97 |
98 | Thank you for contributing to Inpenso!
--------------------------------------------------------------------------------
/iExpense/Components/FormFields.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FormFields.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// Standard text input field with consistent styling
11 | struct TextFormField: View {
12 | let label: String
13 | @Binding var text: String
14 | var placeholder: String = ""
15 | var keyboardType: UIKeyboardType = .default
16 | var leadingIcon: String? = nil
17 | var trailingIcon: String? = nil
18 | var trailingAction: (() -> Void)? = nil
19 |
20 | var body: some View {
21 | VStack(alignment: .leading, spacing: 8) {
22 | // Field label
23 | Text(label)
24 | .font(.subheadline)
25 | .foregroundColor(.secondary)
26 |
27 | // Input field
28 | HStack(alignment: .center) {
29 | if let iconName = leadingIcon {
30 | Image(systemName: iconName)
31 | .foregroundColor(.secondary)
32 | .frame(width: 20)
33 | }
34 |
35 | TextField(placeholder, text: $text)
36 | .keyboardType(keyboardType)
37 |
38 | if let iconName = trailingIcon {
39 | Button(action: {
40 | trailingAction?()
41 | }) {
42 | Image(systemName: iconName)
43 | .foregroundColor(.secondary)
44 | }
45 | .disabled(trailingAction == nil)
46 | }
47 | }
48 | .padding(.vertical, 10)
49 | .padding(.horizontal, 15)
50 | .background(Color(.tertiarySystemBackground))
51 | .cornerRadius(10)
52 | }
53 | }
54 | }
55 |
56 | /// Currency input field with formatting
57 | struct CurrencyFormField: View {
58 | let label: String
59 | @Binding var amount: String
60 | var currencySymbol: String
61 | var clearAction: (() -> Void)? = nil
62 |
63 | var body: some View {
64 | VStack(alignment: .leading, spacing: 8) {
65 | // Field label
66 | Text(label)
67 | .font(.subheadline)
68 | .foregroundColor(.secondary)
69 |
70 | // Currency input field
71 | HStack(alignment: .center) {
72 | Text(currencySymbol)
73 | .foregroundColor(.secondary)
74 | .font(.title3)
75 | .fontWeight(.medium)
76 |
77 | TextField("0.00", text: $amount)
78 | .font(.title2)
79 | .fontWeight(.semibold)
80 | .keyboardType(.decimalPad)
81 | .multilineTextAlignment(.leading)
82 | .onChange(of: amount) {
83 | amount = formatCurrencyInput(amount)
84 | }
85 |
86 | Spacer()
87 |
88 | // Clear button
89 | if !amount.isEmpty {
90 | Button(action: {
91 | if let clearAction = clearAction {
92 | clearAction()
93 | } else {
94 | amount = ""
95 | }
96 | }) {
97 | Image(systemName: "xmark.circle.fill")
98 | .foregroundColor(.secondary)
99 | }
100 | }
101 | }
102 | .padding(.vertical, 10)
103 | .padding(.horizontal, 15)
104 | .background(Color(.tertiarySystemBackground))
105 | .cornerRadius(10)
106 | }
107 | }
108 |
109 | /// Format the input to ensure it's a valid currency value
110 | private func formatCurrencyInput(_ input: String) -> String {
111 | // Remove any non-numeric characters except for a single decimal point
112 | var formattedInput = input.replacingOccurrences(of: ",", with: ".")
113 |
114 | // Allow only one decimal point
115 | let components = formattedInput.components(separatedBy: ".")
116 | if components.count > 2 {
117 | formattedInput = components[0] + "." + components[1]
118 | }
119 |
120 | // Limit to two decimal places
121 | if let decimalIndex = formattedInput.firstIndex(of: ".") {
122 | let decimalPosition = formattedInput.distance(from: formattedInput.startIndex, to: decimalIndex)
123 | let maxLength = decimalPosition + 3 // Allow up to 2 decimal places
124 |
125 | if formattedInput.count > maxLength {
126 | let endIndex = formattedInput.index(formattedInput.startIndex, offsetBy: maxLength)
127 | formattedInput = String(formattedInput[.. URL? {
106 | let expenses = StorageService.loadExpenses()
107 |
108 | do {
109 | let encoder = JSONEncoder()
110 | encoder.outputFormatting = .prettyPrinted
111 | let jsonData = try encoder.encode(expenses)
112 |
113 | let fileManager = FileManager.default
114 | let documentDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
115 | print("DEBUG: exported file \(exportFileName).json")
116 | let fileURL = documentDirectory.appendingPathComponent("\(exportFileName).json")
117 |
118 | try jsonData.write(to: fileURL)
119 | return fileURL
120 | } catch {
121 | return nil
122 | }
123 | }
124 |
125 | func importData(from url: URL) -> Bool {
126 | do {
127 | let data = try Data(contentsOf: url)
128 | let decoder = JSONDecoder()
129 | let expenses = try decoder.decode([Expense].self, from: data)
130 |
131 | StorageService.saveExpenses(expenses)
132 | return true
133 | } catch {
134 | return false
135 | }
136 | }
137 |
138 | func resetAllData() {
139 | StorageService.saveExpenses([])
140 | StorageService.saveBudgets([:])
141 | }
142 |
143 | // Static method to get app-wide settings without needing to initialize
144 | static func getAppCurrency() -> String {
145 | // First try to get from shared defaults
146 | let sharedDefaults = UserDefaults(suiteName: StorageService.appGroupID)
147 |
148 | if let sharedDefaults = sharedDefaults {
149 | // Force sync to make sure we have latest data
150 | sharedDefaults.synchronize()
151 |
152 | if let currency = sharedDefaults.string(forKey: "selectedCurrency") {
153 | return currency
154 | }
155 | }
156 |
157 | // Fall back to standard defaults
158 | return UserDefaults.standard.string(forKey: "selectedCurrency") ?? "USD"
159 | }
160 | }
161 |
162 | // Function to get currency symbol - doesn't use main actor
163 | func getSettingsCurrencySymbol() -> String {
164 | let code = UserDefaults.standard.string(forKey: "selectedCurrency") ?? "USD"
165 | if let currency = availableCurrencies.first(where: { $0.code == code }) {
166 | return currency.symbol
167 | }
168 | return "$" // Default fallback
169 | }
170 |
--------------------------------------------------------------------------------
/PRIVACY.md:
--------------------------------------------------------------------------------
1 | # Privacy Policy
2 |
3 | **Last Updated:** December 2025
4 |
5 | ## Introduction
6 |
7 | Inpenso ("we," "our," or "us") is committed to protecting your privacy. This Privacy Policy explains how we collect, use, store, and protect your information when you use our mobile application ("App").
8 |
9 | By using Inpenso, you agree to the collection and use of information in accordance with this policy.
10 |
11 | ## Information We Collect
12 |
13 | ### Personal Financial Data
14 |
15 | Inpenso collects the following types of data that you voluntarily provide:
16 |
17 | - **Expense Information**: Title, amount, date, category, and optional notes for each expense you record
18 | - **Budget Information**: Monthly budget amounts you set for tracking purposes
19 | - **App Preferences**: Currency selection, default category, and theme preferences (light/dark/system)
20 |
21 | ### Automatically Collected Information
22 |
23 | - **Device Information**: Basic device information necessary for app functionality (iOS version, device type)
24 | - **Usage Data**: Data generated through your use of the app features (expense entries, budget settings)
25 |
26 | ## How We Use Your Information
27 |
28 | We use the collected information solely for the following purposes:
29 |
30 | 1. **Core Functionality**: To provide expense tracking, budget management, and analytics features
31 | 2. **Widget Display**: To display your expense summaries and budget progress on iOS home screen widgets
32 | 3. **Siri Integration**: To enable voice commands and Siri Shortcuts for adding expenses
33 | 4. **App Customization**: To remember your preferences (currency, theme, default category)
34 | 5. **Data Visualization**: To generate charts, trends, and spending insights within the app
35 |
36 | ## Data Storage and Security
37 |
38 | ### Local Storage Only
39 |
40 | **All your data is stored exclusively on your device.** We do not transmit, upload, or sync your data to any external servers, cloud services, or third-party platforms.
41 |
42 | Your data is stored using:
43 | - **UserDefaults** (via App Group: `group.com.vintuss.Inpenso`) for expenses, budgets, and settings
44 | - **SwiftData** (Apple's local database framework) for persistent storage
45 | - **iOS App Group** for secure data sharing between the main app and widget extension
46 |
47 | ### Security Measures
48 |
49 | - All data is encrypted using iOS's built-in encryption mechanisms
50 | - Data access is restricted to the app and its extensions (widgets) only
51 | - No network connectivity is required or used for data storage
52 | - Your financial information never leaves your device
53 |
54 | ## Data Sharing and Disclosure
55 |
56 | **We do not share, sell, trade, or otherwise transfer your personal information to any third parties.**
57 |
58 | Specifically:
59 | - No data is sent to external servers
60 | - No analytics or tracking services are used
61 | - No advertising networks receive your data
62 | - No third-party services have access to your information
63 | - No cloud backup or sync services are utilized
64 |
65 | ### App Extensions
66 |
67 | Your data may be accessed by:
68 | - **Widget Extension**: Reads expense and budget data to display summaries on your home screen
69 | - **Siri Intents Extension**: Allows adding expenses via Siri Shortcuts
70 |
71 | Both extensions operate within the same secure App Group container and do not transmit data externally.
72 |
73 | ## User Rights and Control
74 |
75 | You have complete control over your data:
76 |
77 | ### Access Your Data
78 | - View all expenses and budgets within the app
79 | - Export your data as JSON through the Settings menu
80 |
81 | ### Modify Your Data
82 | - Edit or delete any expense entry
83 | - Update or remove budget settings
84 | - Change app preferences at any time
85 |
86 | ### Delete Your Data
87 | - Delete individual expenses or all expenses
88 | - Reset all app data through Settings
89 | - Uninstall the app to remove all stored data
90 |
91 | ### Data Portability
92 | - Export your expense data in JSON format for backup or transfer to other services
93 |
94 | ## Third-Party Services
95 |
96 | Inpenso does not integrate with any third-party services, analytics platforms, advertising networks, or cloud storage providers. The app operates entirely offline and uses only Apple's native frameworks and APIs.
97 |
98 | ## Children's Privacy
99 |
100 | Inpenso is not intended for children under the age of 13. We do not knowingly collect personal information from children under 13. If you are a parent or guardian and believe your child has provided us with personal information, please contact us immediately.
101 |
102 | ## Data Retention
103 |
104 | Your data is retained on your device until you:
105 | - Delete individual entries
106 | - Reset all app data through Settings
107 | - Uninstall the app
108 |
109 | We do not maintain copies of your data on external servers, so deletion from your device is permanent.
110 |
111 | ## International Data Transfers
112 |
113 | Since all data is stored locally on your device and never transmitted externally, there are no international data transfers involved in using Inpenso.
114 |
115 | ## Changes to This Privacy Policy
116 |
117 | We may update our Privacy Policy from time to time. We will notify you of any changes by:
118 | - Posting the new Privacy Policy in the app
119 | - Updating the "Last Updated" date at the top of this policy
120 | - For significant changes, providing in-app notification
121 |
122 | You are advised to review this Privacy Policy periodically for any changes. Changes to this Privacy Policy are effective when they are posted.
123 |
124 | ## Your Consent
125 |
126 | By using Inpenso, you consent to this Privacy Policy. If you do not agree with this policy, please do not use the app.
127 |
128 | ## Compliance
129 |
130 | This Privacy Policy is designed to comply with:
131 | - **General Data Protection Regulation (GDPR)** - European Union
132 | - **California Consumer Privacy Act (CCPA)** - California, USA
133 | - **Apple App Store Privacy Guidelines**
134 | - **iOS Privacy Requirements**
135 |
136 | ## Contact Us
137 |
138 | If you have any questions about this Privacy Policy or our data practices, please contact us:
139 |
140 | - **Email**: dmindrescu03@gmail.com
141 | - **GitHub**: [https://github.com/VintusS/Inpenso](https://github.com/VintusS/Inpenso)
142 |
143 | ## Summary
144 |
145 | **Key Points:**
146 | - All data stored locally on your device
147 | - No data transmitted to external servers
148 | - No third-party services or analytics
149 | - Complete user control over data
150 | - No advertising or tracking
151 | - Secure encryption using iOS mechanisms
152 | - Data accessible only to you and the app
153 |
154 | Your privacy is our priority. Inpenso is designed with privacy-first principles, ensuring your financial data remains private and secure on your device.
155 |
156 | ---
157 |
158 | **Developer:** Dragomir Mindrescu
159 | **App Name:** Inpenso
160 | **Version:** 5
161 | **Platform:** iOS 18.0+
162 |
163 |
--------------------------------------------------------------------------------
/iExpense/Services/SwiftDataManager.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SwiftDataManager.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import Foundation
9 | import SwiftData
10 |
11 | // This class will manage all SwiftData operations
12 | // It doesn't modify any existing functionality and can be used in parallel
13 | class SwiftDataManager {
14 |
15 | // Singleton instance
16 | static let shared = SwiftDataManager()
17 |
18 | // Private initializer for singleton
19 | private init() {}
20 |
21 | // MARK: - Create a ModelContainer
22 |
23 | func createContainer() throws -> ModelContainer {
24 | let schema = Schema([
25 | ExpenseItem.self,
26 | BudgetItem.self
27 | ])
28 |
29 | let modelConfiguration = ModelConfiguration(
30 | schema: schema,
31 | isStoredInMemoryOnly: false,
32 | allowsSave: true
33 | )
34 |
35 | return try ModelContainer(for: schema, configurations: [modelConfiguration])
36 | }
37 |
38 | // MARK: - Migration
39 |
40 | // Migrates data from UserDefaults to SwiftData without affecting current functionality
41 | func migrateData(using context: ModelContext, silent: Bool = true) async throws {
42 | // Check if migration has already been done
43 | if UserDefaults.standard.bool(forKey: "swiftDataMigrationCompleted") {
44 | return
45 | }
46 |
47 | // Migrate expenses
48 | try await migrateExpenses(using: context, silent: silent)
49 |
50 | // Migrate budgets
51 | try await migrateBudgets(using: context, silent: silent)
52 |
53 | // Mark migration as completed
54 | UserDefaults.standard.set(true, forKey: "swiftDataMigrationCompleted")
55 | }
56 |
57 | private func migrateExpenses(using context: ModelContext, silent: Bool = true) async throws {
58 | // Load expenses from UserDefaults
59 | let existingExpenses = StorageService.loadExpenses()
60 |
61 | // Convert and save each expense
62 | for expense in existingExpenses {
63 | // Get notes (if any)
64 | let notesKey = "notes_\(expense.id.uuidString)"
65 | let notes = UserDefaults.standard.string(forKey: notesKey)
66 |
67 | // Create the ExpenseItem
68 | let item = ExpenseItem(
69 | id: expense.id.uuidString,
70 | name: expense.title,
71 | amount: expense.price,
72 | date: expense.date,
73 | categoryName: expense.category.rawValue,
74 | notes: notes
75 | )
76 |
77 | context.insert(item)
78 | }
79 |
80 | try context.save()
81 |
82 | if !silent {
83 | print("Migrated \(existingExpenses.count) expenses to SwiftData")
84 | }
85 | }
86 |
87 | private func migrateBudgets(using context: ModelContext, silent: Bool = true) async throws {
88 | // Load budgets from UserDefaults
89 | let existingBudgets = StorageService.loadBudgets()
90 |
91 | // Convert and save each budget
92 | for (monthYear, amount) in existingBudgets {
93 | let budget = BudgetItem(monthYear: monthYear, amount: amount)
94 | context.insert(budget)
95 | }
96 |
97 | try context.save()
98 |
99 | if !silent {
100 | print("Migrated \(existingBudgets.count) budgets to SwiftData")
101 | }
102 | }
103 |
104 | // MARK: - Read Operations
105 |
106 | func getAllExpenses(using context: ModelContext) throws -> [ExpenseItem] {
107 | let descriptor = FetchDescriptor(sortBy: [SortDescriptor(\.date, order: .reverse)])
108 | return try context.fetch(descriptor)
109 | }
110 |
111 | func getMonthlyExpenses(for date: Date, using context: ModelContext) throws -> [ExpenseItem] {
112 | let calendar = Calendar.current
113 | let month = calendar.component(.month, from: date)
114 | let year = calendar.component(.year, from: date)
115 |
116 | let startOfMonth = calendar.date(from: DateComponents(year: year, month: month, day: 1))!
117 | let nextMonth = calendar.date(byAdding: .month, value: 1, to: startOfMonth)!
118 |
119 | let descriptor = FetchDescriptor(
120 | predicate: #Predicate { expense in
121 | expense.date >= startOfMonth && expense.date < nextMonth
122 | },
123 | sortBy: [SortDescriptor(\.date, order: .reverse)]
124 | )
125 |
126 | return try context.fetch(descriptor)
127 | }
128 |
129 | func getBudget(for date: Date, using context: ModelContext) throws -> BudgetItem? {
130 | let monthYear = BudgetItem.monthYearString(from: date)
131 |
132 | let descriptor = FetchDescriptor(
133 | predicate: #Predicate { budget in
134 | budget.monthYear == monthYear
135 | }
136 | )
137 |
138 | return try context.fetch(descriptor).first
139 | }
140 |
141 | // MARK: - Write Operations
142 |
143 | func saveExpense(_ expense: Expense, using context: ModelContext) throws {
144 | // Get notes for this expense if they exist
145 | let notesKey = "notes_\(expense.id.uuidString)"
146 | let notes = UserDefaults.standard.string(forKey: notesKey)
147 |
148 | // Create the ExpenseItem
149 | let item = ExpenseItem(
150 | id: expense.id.uuidString,
151 | name: expense.title,
152 | amount: expense.price,
153 | date: expense.date,
154 | categoryName: expense.category.rawValue,
155 | notes: notes
156 | )
157 |
158 | context.insert(item)
159 | try context.save()
160 | }
161 |
162 | func saveBudget(monthYear: String, amount: Double, using context: ModelContext) throws {
163 | // Check if budget already exists
164 | let descriptor = FetchDescriptor(
165 | predicate: #Predicate { budget in
166 | budget.monthYear == monthYear
167 | }
168 | )
169 |
170 | if let existingBudget = try context.fetch(descriptor).first {
171 | // Update existing budget
172 | existingBudget.amount = amount
173 | } else {
174 | // Create new budget
175 | let budget = BudgetItem(monthYear: monthYear, amount: amount)
176 | context.insert(budget)
177 | }
178 |
179 | try context.save()
180 | }
181 |
182 | func deleteExpense(id: String, using context: ModelContext) throws {
183 | let descriptor = FetchDescriptor(
184 | predicate: #Predicate { expense in
185 | expense.id == id
186 | }
187 | )
188 |
189 | if let item = try context.fetch(descriptor).first {
190 | context.delete(item)
191 | try context.save()
192 | }
193 | }
194 | }
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/MonthYearPicker.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MonthYearPicker.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | /// A month-year pair for date selection
11 | struct MonthYear: Identifiable, Equatable {
12 | var id: String { "\(month)-\(year)" }
13 | let month: Int
14 | let year: Int
15 |
16 | var displayName: String {
17 | let calendar = Calendar.current
18 | return "\(calendar.monthSymbols[month - 1]) \(String(year))"
19 | }
20 |
21 | /// Returns true if this month-year is in the future
22 | func isFuture(relativeTo date: Date = Date()) -> Bool {
23 | let calendar = Calendar.current
24 | let currentMonth = calendar.component(.month, from: date)
25 | let currentYear = calendar.component(.year, from: date)
26 |
27 | return (year > currentYear) || (year == currentYear && month > currentMonth)
28 | }
29 | }
30 |
31 | /// A swipeable month-year picker that restricts selection to past months only
32 | struct MonthYearPicker: View {
33 | @Binding var selectedMonth: Int
34 | @Binding var selectedYear: Int
35 | private let monthYearList: [MonthYear]
36 | private let allowFutureMonths: Bool
37 | var onMonthYearChanged: (() -> Void)? = nil
38 |
39 | @State private var selectedIndex: Int = 0
40 |
41 | init(
42 | selectedMonth: Binding,
43 | selectedYear: Binding,
44 | monthsToShow: Int = 36,
45 | allowFutureMonths: Bool = false,
46 | onMonthYearChanged: (() -> Void)? = nil
47 | ) {
48 | self._selectedMonth = selectedMonth
49 | self._selectedYear = selectedYear
50 | self.onMonthYearChanged = onMonthYearChanged
51 | self.allowFutureMonths = allowFutureMonths
52 |
53 | // Generate the month-year list
54 | var list: [MonthYear] = []
55 | let totalMonths = max(monthsToShow, 1)
56 | let calendar = Calendar.current
57 | if let today = calendar.date(bySettingHour: 0, minute: 0, second: 0, of: Date()) {
58 | // Start from the oldest month and go towards the newest
59 | for i in (0.. 0) {
78 | Button(action: {
79 | if selectedIndex > 0 {
80 | selectedIndex -= 1
81 | }
82 | }) {
83 | Image(systemName: "chevron.left")
84 | .font(.title2)
85 | .foregroundColor(.primary.opacity(selectedIndex > 0 ? 1 : 0))
86 | }
87 | .disabled(selectedIndex == 0)
88 | }
89 |
90 | VStack(spacing: 0) {
91 | TabView(selection: $selectedIndex) {
92 | ForEach(Array(monthYearList.enumerated()), id: \.element.id) { index, monthYear in
93 | HStack(spacing: 8) {
94 | Text(Calendar.current.monthSymbols[monthYear.month - 1])
95 | .font(.title2)
96 | .fontWeight(.bold)
97 |
98 | Text(String(monthYear.year))
99 | .font(.title3)
100 | .foregroundColor(.secondary)
101 | }
102 | .padding(.vertical, 10)
103 | .frame(maxWidth: .infinity)
104 | .tag(index)
105 | }
106 | }
107 | .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never))
108 | .frame(height: 50)
109 | .onChange(of: selectedIndex) {
110 | guard monthYearList.indices.contains(selectedIndex) else { return }
111 | let monthYear = monthYearList[selectedIndex]
112 |
113 | // Prevent selecting future months
114 | if !allowFutureMonths && monthYear.isFuture() {
115 | // Revert to previous selection
116 | selectedIndex = monthYearList.firstIndex(where: { $0.month == selectedMonth && $0.year == selectedYear }) ?? 0
117 | HapticFeedback.error()
118 | } else {
119 | // Update selection
120 | selectedMonth = monthYear.month
121 | selectedYear = monthYear.year
122 | HapticFeedback.selection()
123 | onMonthYearChanged?()
124 | }
125 | }
126 |
127 | // Month indicator dots just like in ExpensesListView
128 | HStack(spacing: 4) {
129 | ForEach(1...12, id: \.self) { month in
130 | Circle()
131 | .fill(month == selectedMonth ? Color.accentColor : Color.gray.opacity(0.3))
132 | .frame(width: 6, height: 6)
133 | }
134 | }
135 | .padding(.bottom, 8)
136 | }
137 | .onAppear {
138 | // Synchronize the picker with the current selection
139 | if let index = monthYearList.firstIndex(where: { $0.month == selectedMonth && $0.year == selectedYear }) {
140 | selectedIndex = index
141 | }
142 | }
143 |
144 | // MARK: - Right Arrow Button
145 | Button(action: {
146 | if selectedIndex < monthYearList.count - 1 {
147 | selectedIndex += 1
148 | }
149 | }) {
150 | Image(systemName: "chevron.right")
151 | .font(.title2)
152 | .foregroundColor(.primary.opacity(selectedIndex < monthYearList.count - 1 ? 1 : 0))
153 | }
154 | .disabled(selectedIndex == monthYearList.count - 1 )
155 | }
156 | }
157 | }
158 |
159 | #Preview(traits: .sizeThatFitsLayout) {
160 | VStack(spacing: 20) {
161 | MonthYearPicker(
162 | selectedMonth: .constant(Calendar.current.component(.month, from: Date())),
163 | selectedYear: .constant(Calendar.current.component(.year, from: Date()))
164 | )
165 | }
166 | .padding()
167 | }
168 |
--------------------------------------------------------------------------------
/iExpense/ViewTests/TestRecentSpending.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import Charts
10 |
11 | struct HomeViewTest: View {
12 | @ObservedObject var viewModel: ExpenseViewModel
13 | @ObservedObject var analyticsViewModel: AnalyticsViewModel
14 | @State private var showingAddExpense = false
15 | @State private var showRecentExpenses = true
16 | @State private var animateCards = false
17 | @State private var selectedExpenseToEdit: Expense? = nil
18 | @State private var showingEditExpense = false
19 |
20 | private let recentDaysToShow = 7
21 |
22 | var body: some View {
23 | NavigationView {
24 | ScrollView {
25 | VStack(spacing: 20) {
26 | recentSpendingCard
27 | }
28 | .padding(.horizontal)
29 | .padding(.bottom, 20)
30 | }
31 | .navigationTitle("Home")
32 | .toolbar {
33 | ToolbarItem(placement: .navigationBarTrailing) {
34 | Button(action: {
35 | showingAddExpense = true
36 | }) {
37 | HStack(spacing: 4) {
38 | Image(systemName: "plus")
39 | Text("Add")
40 | .font(.callout)
41 | .fontWeight(.semibold)
42 | }
43 | .padding(.horizontal, 10)
44 | .padding(.vertical, 6)
45 | .cornerRadius(20)
46 | }
47 | }
48 | }
49 | .sheet(isPresented: $showingAddExpense) {
50 | AddExpenseView(viewModel: viewModel)
51 | }
52 | .sheet(item: $selectedExpenseToEdit) { expense in
53 | EditExpenseView(viewModel: viewModel, expense: expense)
54 | }
55 | .onAppear {
56 | // Animate cards when view appears with slight delay between each
57 | withAnimation(.easeOut(duration: 0.5).delay(0.1)) {
58 | animateCards = true
59 | }
60 | }
61 | }
62 | }
63 |
64 |
65 |
66 | // MARK: - Recent Spending Card
67 |
68 | private var recentSpendingCard: some View {
69 | VStack(alignment: .leading, spacing: 16) {
70 | Text("Recent Spending")
71 | .font(.headline)
72 |
73 | if analyticsViewModel.dailySpending.isEmpty {
74 | Text("No recent spending data")
75 | .foregroundColor(.secondary)
76 | .frame(maxWidth: .infinity, alignment: .center)
77 | .padding(.vertical)
78 | } else {
79 | // Get most recent spending data from the daily spending array
80 | let recentSpending: [DailySpending] = getRecentSpendingData()
81 |
82 | // Check if we have any actual spending in this period
83 | let totalRecentSpending = recentSpending.reduce(0.0) { $0 + $1.amount }
84 |
85 | if totalRecentSpending <= 0 {
86 | Text("No spending in the last \(recentDaysToShow) days")
87 | .foregroundColor(.secondary)
88 | .frame(maxWidth: .infinity, alignment: .center)
89 | .padding(.vertical)
90 | } else {
91 | // Find max value for better scaling
92 | let maxValue = recentSpending.map { $0.amount }.max() ?? 0
93 |
94 | VStack(spacing: 8) {
95 | Chart {
96 | ForEach(recentSpending, id: \.dayOfMonth) { daily in
97 | BarMark(
98 | x: .value("Day", daily.weekday),
99 | y: .value("Amount", daily.amount)
100 | )
101 | .foregroundStyle(
102 | .linearGradient(
103 | colors: [.blue.opacity(0.7), .blue],
104 | startPoint: .bottom,
105 | endPoint: .top
106 | )
107 | )
108 | .cornerRadius(6)
109 | }
110 |
111 | if analyticsViewModel.averageDailySpend > 0 {
112 | RuleMark(
113 | y: .value("Average", analyticsViewModel.averageDailySpend)
114 | )
115 | .lineStyle(StrokeStyle(lineWidth: 1.5, dash: [5, 5]))
116 | .foregroundStyle(Color.green)
117 | .annotation(position: .top, alignment: .trailing) {
118 | Text("Avg")
119 | .font(.caption2)
120 | .foregroundColor(.green)
121 | .padding(4)
122 | .background(Color(.tertiarySystemBackground))
123 | .cornerRadius(4)
124 | }
125 | }
126 | }
127 | .frame(height: 180)
128 | .chartYAxis {
129 | AxisMarks(position: .leading)
130 | }
131 | // Enforce minimum scale if values are very small
132 | .chartYScale(domain: 0...(max(maxValue * 1.2, analyticsViewModel.averageDailySpend * 1.2, 1)))
133 |
134 | // Add a note about the data
135 | Text("Showing spending for the last \(recentDaysToShow) days")
136 | .font(.caption)
137 | .foregroundColor(.secondary)
138 | .frame(maxWidth: .infinity, alignment: .center)
139 | }
140 | }
141 | }
142 | }
143 | .padding()
144 | .background(
145 | RoundedRectangle(cornerRadius: 20)
146 | .fill(Color(.secondarySystemBackground))
147 | .shadow(color: Color.black.opacity(0.05), radius: 10, x: 0, y: 5)
148 | )
149 | .offset(y: animateCards ? 0 : -30)
150 | .opacity(animateCards ? 1 : 0)
151 | }
152 |
153 | // Helper function to get recent spending data
154 | private func getRecentSpendingData() -> [DailySpending] {
155 | var recentSpending: [DailySpending] = []
156 |
157 | // For debugging and comprehensive data, let's look at all daily spending
158 | let allDays = analyticsViewModel.dailySpending
159 |
160 | // Find the last 7 days, including today
161 | let calendar = Calendar.current
162 | let today = calendar.startOfDay(for: Date())
163 |
164 | for i in 0.. some View {
101 | Text("\(currency.symbol) \(currency.name) (\(currency.code))")
102 | .tag(currency.code)
103 | }
104 |
105 | private var defaultSettingsSection: some View {
106 | Section(header: Text("Default Settings")) {
107 | categoryPicker
108 | }
109 | }
110 |
111 | private var categoryPicker: some View {
112 | Picker("Default Category", selection: $settingsManager.defaultCategory) {
113 | ForEach(Category.allCases, id: \.self) { category in
114 | categoryRow(for: category)
115 | }
116 | }
117 | .pickerStyle(.menu)
118 | }
119 |
120 | private func categoryRow(for category: Category) -> some View {
121 | HStack {
122 | Circle()
123 | .fill(category.color)
124 | .frame(width: 12, height: 12)
125 | Text(category.displayName).tag(category)
126 | }
127 | }
128 |
129 | private var dataManagementSection: some View {
130 | Section(header: Text("Data Management")) {
131 | exportButton
132 | importButton
133 | resetButton
134 | }
135 | }
136 |
137 | private var exportButton: some View {
138 | Button(action: {
139 | exportData()
140 | }) {
141 | Label("Export Data", systemImage: "square.and.arrow.up")
142 | }
143 | }
144 |
145 | private var importButton: some View {
146 | Button(action: {
147 | showingImportFilePicker = true
148 | }) {
149 | Label("Import Data", systemImage: "square.and.arrow.down")
150 | }
151 | }
152 |
153 | private var resetButton: some View {
154 | Button(role: .destructive, action: {
155 | showingResetConfirmation = true
156 | }) {
157 | Label("Reset All Data", systemImage: "trash")
158 | }
159 | .foregroundColor(.red)
160 | }
161 |
162 | private var aboutSection: some View {
163 | Section(header: Text("About")) {
164 | versionRow
165 | }
166 | }
167 |
168 | private var versionRow: some View {
169 | HStack {
170 | Text("Version")
171 | Spacer()
172 | Text(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0")
173 | .foregroundColor(.secondary)
174 | }
175 | }
176 |
177 | private var documentPicker: some View {
178 | DocumentPicker(
179 | types: [UTType.json],
180 | allowsMultipleSelection: false
181 | ) { urls in
182 | guard let url = urls.first else { return }
183 | let success = settingsManager.importData(from: url)
184 | if success {
185 | showingImportSuccess = true
186 | } else {
187 | showingImportFailure = true
188 | }
189 | }
190 | }
191 |
192 | private var shareSheet: some View {
193 | Group {
194 | if let url = exportURL {
195 | ShareSheet(items: [url])
196 | }
197 | }
198 | }
199 |
200 | private var resetAlertButtons: some View {
201 | Group {
202 | Button("Cancel", role: .cancel) { }
203 | Button("Reset", role: .destructive) {
204 | settingsManager.resetAllData()
205 | }
206 | }
207 | }
208 |
209 | private func exportData() {
210 | if let url = settingsManager.exportData() {
211 | exportURL = url
212 | showingExportShareSheet = true
213 | showingExportSuccess = true
214 | }
215 | }
216 | }
217 |
218 | // Document Picker for importing files
219 | struct DocumentPicker: UIViewControllerRepresentable {
220 | let types: [UTType]
221 | let allowsMultipleSelection: Bool
222 | let onPick: ([URL]) -> Void
223 |
224 | func makeUIViewController(context: Context) -> UIDocumentPickerViewController {
225 | let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
226 | picker.allowsMultipleSelection = allowsMultipleSelection
227 | picker.delegate = context.coordinator
228 | return picker
229 | }
230 |
231 | func updateUIViewController(_ uiViewController: UIDocumentPickerViewController, context: Context) {}
232 |
233 | func makeCoordinator() -> Coordinator {
234 | Coordinator(self)
235 | }
236 |
237 | class Coordinator: NSObject, UIDocumentPickerDelegate {
238 | let parent: DocumentPicker
239 |
240 | init(_ parent: DocumentPicker) {
241 | self.parent = parent
242 | }
243 |
244 | func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
245 | parent.onPick(urls)
246 | }
247 | }
248 | }
249 |
250 | // ShareSheet for exporting files
251 | struct ShareSheet: UIViewControllerRepresentable {
252 | let items: [Any]
253 |
254 | func makeUIViewController(context: Context) -> UIActivityViewController {
255 | let controller = UIActivityViewController(activityItems: items, applicationActivities: nil)
256 | return controller
257 | }
258 |
259 | func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {}
260 | }
261 |
262 | #Preview {
263 | SettingsView()
264 | .environmentObject(SettingsViewModel())
265 | }
266 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/InsightsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // InsightsView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 |
9 | import SwiftUI
10 |
11 | // Add Identifiable conformance to SpendingInsight from AnalyticsViewModel
12 | extension SpendingInsight: Identifiable {
13 | public var id: String { title }
14 | }
15 |
16 | /// A component that displays key spending statistics
17 | struct KeyStatisticsView: View {
18 | let biggestExpenseCategory: (category: Category, amount: Double)?
19 | let totalSpent: Double
20 | let mostActiveSpendingPeriod: String?
21 | let currencyCode: String
22 |
23 | init(
24 | biggestExpenseCategory: (category: Category, amount: Double)?,
25 | totalSpent: Double,
26 | mostActiveSpendingPeriod: String?,
27 | currencyCode: String? = nil
28 | ) {
29 | self.biggestExpenseCategory = biggestExpenseCategory
30 | self.totalSpent = totalSpent
31 | self.mostActiveSpendingPeriod = mostActiveSpendingPeriod
32 | self.currencyCode = currencyCode ?? SettingsViewModel.getAppCurrency()
33 | }
34 |
35 | var body: some View {
36 | VStack(alignment: .leading, spacing: 16) {
37 | Text("Key Statistics")
38 | .font(.headline)
39 |
40 | // Biggest expense category
41 | if let (category, amount) = biggestExpenseCategory {
42 | HStack {
43 | VStack(alignment: .leading, spacing: 4) {
44 | Text("Top Spending Category")
45 | .font(.subheadline)
46 | .foregroundColor(.secondary)
47 |
48 | HStack {
49 | Circle()
50 | .fill(category.color)
51 | .frame(width: 10, height: 10)
52 |
53 | Text(category.displayName)
54 | .font(.system(size: 18, weight: .bold))
55 | }
56 | }
57 |
58 | Spacer()
59 |
60 | VStack(alignment: .trailing, spacing: 4) {
61 | if totalSpent > 0 {
62 | Text("\(Int((amount / totalSpent) * 100))% of total")
63 | .font(.caption)
64 | .foregroundColor(.secondary)
65 | }
66 |
67 | Text(amount, format: .currency(code: currencyCode))
68 | .font(.system(size: 18, weight: .bold))
69 | }
70 | }
71 | .padding()
72 | .background(
73 | RoundedRectangle(cornerRadius: 12)
74 | .fill(Color(.secondarySystemBackground))
75 | )
76 | }
77 |
78 | // Most active spending period
79 | if let period = mostActiveSpendingPeriod {
80 | HStack {
81 | VStack(alignment: .leading, spacing: 4) {
82 | Text("Most Active Days")
83 | .font(.subheadline)
84 | .foregroundColor(.secondary)
85 |
86 | Text(period)
87 | .font(.system(size: 18, weight: .bold))
88 | }
89 |
90 | Spacer()
91 |
92 | Image(systemName: "calendar.badge.clock")
93 | .font(.system(size: 24))
94 | .foregroundColor(.blue)
95 | }
96 | .padding()
97 | .background(
98 | RoundedRectangle(cornerRadius: 12)
99 | .fill(Color(.secondarySystemBackground))
100 | )
101 | }
102 | }
103 | }
104 | }
105 |
106 | /// A component that displays a collection of spending insights
107 | struct InsightsCardView: View {
108 | let insights: [SpendingInsight]
109 |
110 | var body: some View {
111 | VStack(alignment: .leading, spacing: 12) {
112 | Text("Smart Insights")
113 | .font(.headline)
114 |
115 | if insights.isEmpty {
116 | Text("No insights available yet")
117 | .foregroundColor(.secondary)
118 | .padding()
119 | .frame(maxWidth: .infinity, alignment: .center)
120 | .background(
121 | RoundedRectangle(cornerRadius: 12)
122 | .fill(Color(.secondarySystemBackground))
123 | )
124 | } else {
125 | ForEach(insights) { insight in
126 | insightCard(insight: insight)
127 | }
128 | }
129 | }
130 | }
131 |
132 | private func insightCard(insight: SpendingInsight) -> some View {
133 | HStack(spacing: 16) {
134 | // Icon
135 | Image(systemName: insight.icon)
136 | .font(.system(size: 28))
137 | .foregroundColor(insight.color)
138 | .frame(width: 36)
139 |
140 | // Content
141 | VStack(alignment: .leading, spacing: 4) {
142 | Text(insight.title)
143 | .font(.headline)
144 |
145 | Text(insight.description)
146 | .font(.subheadline)
147 | .foregroundColor(.secondary)
148 | }
149 | }
150 | .padding()
151 | .frame(maxWidth: .infinity, alignment: .leading)
152 | .background(
153 | RoundedRectangle(cornerRadius: 12)
154 | .fill(Color(.secondarySystemBackground))
155 | )
156 | }
157 | }
158 |
159 | /// A component that displays spending pattern analysis
160 | struct SpendingPatternView: View {
161 | struct PatternAnalysis {
162 | let title: String
163 | let description: String
164 | let icon: String
165 | let color: Color
166 | }
167 |
168 | let weekdayVsWeekendAnalysis: PatternAnalysis?
169 | let monthlyPatternAnalysis: PatternAnalysis?
170 |
171 | var body: some View {
172 | VStack(alignment: .leading, spacing: 12) {
173 | Text("Spending Patterns")
174 | .font(.headline)
175 |
176 | if weekdayVsWeekendAnalysis == nil && monthlyPatternAnalysis == nil {
177 | Text("Not enough data")
178 | .foregroundColor(.secondary)
179 | .padding()
180 | .frame(maxWidth: .infinity, alignment: .center)
181 | } else {
182 | // Analyze weekdays vs weekends
183 | if let analysis = weekdayVsWeekendAnalysis {
184 | HStack {
185 | VStack(alignment: .leading, spacing: 4) {
186 | Text("Weekday vs Weekend")
187 | .font(.subheadline)
188 | .foregroundColor(.secondary)
189 |
190 | Text(analysis.title)
191 | .font(.system(size: 16, weight: .semibold))
192 |
193 | Text(analysis.description)
194 | .font(.caption)
195 | .foregroundColor(.secondary)
196 | }
197 |
198 | Spacer()
199 |
200 | Image(systemName: analysis.icon)
201 | .font(.system(size: 24))
202 | .foregroundColor(analysis.color)
203 | }
204 | .padding()
205 | .background(
206 | RoundedRectangle(cornerRadius: 12)
207 | .fill(Color(.tertiarySystemBackground))
208 | )
209 | }
210 |
211 | // Analyze beginning vs end of month
212 | if let analysis = monthlyPatternAnalysis {
213 | HStack {
214 | VStack(alignment: .leading, spacing: 4) {
215 | Text("Monthly Pattern")
216 | .font(.subheadline)
217 | .foregroundColor(.secondary)
218 |
219 | Text(analysis.title)
220 | .font(.system(size: 16, weight: .semibold))
221 |
222 | Text(analysis.description)
223 | .font(.caption)
224 | .foregroundColor(.secondary)
225 | }
226 |
227 | Spacer()
228 |
229 | Image(systemName: analysis.icon)
230 | .font(.system(size: 24))
231 | .foregroundColor(analysis.color)
232 | }
233 | .padding()
234 | .background(
235 | RoundedRectangle(cornerRadius: 12)
236 | .fill(Color(.tertiarySystemBackground))
237 | )
238 | }
239 | }
240 | }
241 | .padding()
242 | .background(
243 | RoundedRectangle(cornerRadius: 12)
244 | .fill(Color(.secondarySystemBackground))
245 | )
246 | }
247 | }
248 |
249 | #Preview {
250 | VStack(spacing: 20) {
251 | // Key Statistics preview
252 | KeyStatisticsView(
253 | biggestExpenseCategory: (Category.food, 450.50),
254 | totalSpent: 1850.25,
255 | mostActiveSpendingPeriod: "Wednesday"
256 | )
257 |
258 | // Insights preview
259 | InsightsCardView(insights: [
260 | SpendingInsight(
261 | type: .positive,
262 | title: "Weekend Spending Trend",
263 | description: "You tend to spend 45% more on weekends compared to weekdays",
264 | icon: "calendar.badge.exclamationmark",
265 | color: .orange
266 | ),
267 | SpendingInsight(
268 | type: .negative,
269 | title: "Food Spending Increasing",
270 | description: "Your food spending has increased by 20% compared to last month",
271 | icon: "fork.knife",
272 | color: .red
273 | )
274 | ])
275 |
276 | // Patterns preview
277 | SpendingPatternView(
278 | weekdayVsWeekendAnalysis: SpendingPatternView.PatternAnalysis(
279 | title: "Weekend Spender",
280 | description: "You spend 145% more on weekends compared to weekdays",
281 | icon: "party.popper",
282 | color: .orange
283 | ),
284 | monthlyPatternAnalysis: SpendingPatternView.PatternAnalysis(
285 | title: "End of Month Spender",
286 | description: "Your spending increases toward the end of the month",
287 | icon: "calendar.badge.exclamationmark",
288 | color: .red
289 | )
290 | )
291 | }
292 | .padding()
293 | }
294 |
--------------------------------------------------------------------------------
/iExpense/Views/AddExpenseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AddExpenseView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct AddExpenseView: View {
11 | @Environment(\.dismiss) var dismiss
12 | @Environment(\.colorScheme) var colorScheme
13 | @ObservedObject var viewModel: ExpenseViewModel
14 | @StateObject private var settingsViewModel = SettingsViewModel()
15 |
16 | // Form fields
17 | @State private var title: String = ""
18 | @State private var price: String = ""
19 | @State private var selectedCategory: Category
20 | @State private var selectedDate: Date = Date()
21 | @State private var notes: String = ""
22 |
23 | // UI States
24 | @State private var showDatePicker = false
25 | @State private var keyboardHeight: CGFloat = 0
26 | @State private var keyboardVisible: Bool = false
27 | @State private var showingValidationAlert = false
28 | @State private var validationMessage = ""
29 | @State private var animateSuccess = false
30 |
31 | // Current currency symbol
32 | private var currencySymbol: String {
33 | let locale = Locale.current
34 | let currencyCode = SettingsViewModel.getAppCurrency()
35 | return locale.localizedCurrencySymbol(forCurrencyCode: currencyCode) ?? currencyCode
36 | }
37 |
38 | init(viewModel: ExpenseViewModel) {
39 | self.viewModel = viewModel
40 | // Initialize with the default category from settings
41 | let defaultCategory = UserDefaults.standard.string(forKey: "defaultCategory") ?? Category.food.rawValue
42 | _selectedCategory = State(initialValue: Category(rawValue: defaultCategory) ?? .food)
43 | }
44 |
45 | var body: some View {
46 | NavigationView {
47 | ZStack {
48 | Color(.systemGroupedBackground)
49 | .ignoresSafeArea()
50 |
51 | ScrollView {
52 | VStack(spacing: 20) {
53 | // Title and amount card
54 | mainDataCard
55 |
56 | // Category selection
57 | CardView(title: "Category") {
58 | CategoryGrid(selectedCategory: $selectedCategory)
59 | .padding(.horizontal)
60 | }
61 |
62 | // Date selection
63 | DatePickerCard(
64 | title: "Date",
65 | selectedDate: $selectedDate,
66 | isExpanded: $showDatePicker
67 | )
68 |
69 | // Notes
70 | notesCard
71 |
72 | // Save Button
73 | if #available(iOS 26.0, *) {
74 | saveButton
75 | .glassEffect(isFormValid() ? .regular.tint(.blue).interactive() : .regular.tint(.gray))
76 | } else {
77 | saveButton
78 | .background(isFormValid() ? Color.accentColor : Color.gray)
79 | .clipShape(RoundedRectangle(cornerRadius: 16))
80 | }
81 | }
82 | .padding(.horizontal)
83 | .padding(.top, 10)
84 | .padding(.bottom, keyboardHeight > 0 ? keyboardHeight - 40 : 20)
85 | }
86 |
87 | // Success animation overlay
88 | if animateSuccess {
89 | successOverlay
90 | }
91 | }
92 | .navigationTitle("Add Expense")
93 | .navigationBarTitleDisplayMode(.inline)
94 | .toolbar {
95 | ToolbarItem(placement: .navigationBarLeading) {
96 | Button("Cancel") {
97 | dismiss()
98 | }
99 | }
100 |
101 | // Done button only shows when keyboard is visible
102 | ToolbarItem(placement: .navigationBarTrailing) {
103 | if keyboardVisible {
104 | Button("Done") {
105 | hideKeyboard()
106 | }
107 | }
108 | }
109 | }
110 | .onAppear {
111 | setupKeyboardObservers()
112 | }
113 | .onDisappear {
114 | removeKeyboardObservers()
115 | }
116 | .alert(validationMessage, isPresented: $showingValidationAlert) {
117 | Button("OK", role: .cancel) { }
118 | }
119 | }
120 | }
121 |
122 | // MARK: - Main Data Card
123 |
124 | private var mainDataCard: some View {
125 | CardView(title: "Expense Details", showDivider: true) {
126 | VStack(spacing: 16) {
127 | // Title field
128 | TextFormField(
129 | label: "Title",
130 | text: $title,
131 | placeholder: "Expense title",
132 | leadingIcon: "pencil"
133 | )
134 | .padding(.horizontal)
135 |
136 | // Price field
137 | CurrencyFormField(
138 | label: "Amount",
139 | amount: $price,
140 | currencySymbol: currencySymbol,
141 | clearAction: { price = "" }
142 | )
143 | .padding(.horizontal)
144 | .padding(.bottom, 8)
145 | }
146 | }
147 | }
148 |
149 | // MARK: - Notes Card
150 |
151 | private var notesCard: some View {
152 | CardView(title: "Notes (Optional)") {
153 | ZStack(alignment: .topLeading) {
154 | // Background that adapts to color scheme
155 | RoundedRectangle(cornerRadius: 10)
156 | .fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemBackground))
157 | .frame(minHeight: 100)
158 |
159 | // Text editor
160 | TextEditor(text: $notes)
161 | .font(.body)
162 | .scrollContentBackground(.hidden) // Hide the default background
163 | .background(Color.clear) // Use transparent background
164 | .padding(8)
165 | .frame(minHeight: 100)
166 | }
167 | .padding(.horizontal)
168 | .padding(.bottom, 8)
169 | }
170 | }
171 |
172 | // MARK: - Save Button
173 |
174 | private var saveButton: some View {
175 | Button(action: saveExpense) {
176 | HStack {
177 | Spacer()
178 | Text("Save Expense")
179 | Spacer()
180 | }
181 | .padding()
182 | .foregroundColor(.white)
183 |
184 | // HStack {
185 | // Spacer()
186 | // Text("Save Expense")
187 | // .fontWeight(.bold)
188 | // Spacer()
189 | // }
190 | // .padding()
191 | // .background(isFormValid() ? Color.accentColor : Color.gray)
192 | // .foregroundColor(.white)
193 | // .cornerRadius(16)
194 | }
195 | .disabled(!isFormValid())
196 | }
197 |
198 | // let saveButton: some View =
199 | //
200 | // if #available(iOS 26.0, *) {
201 | // saveButton
202 | // .glassEffect(.regular.tint(.blue).interactive())
203 | // } else {
204 | // saveButton
205 | // .background(Color.blue.opacity(0.8))
206 | // .cornerRadius(12)
207 | // }
208 |
209 | // MARK: - Success Overlay
210 |
211 | private var successOverlay: some View {
212 | ZStack {
213 | Color.black.opacity(0.4)
214 | .ignoresSafeArea()
215 |
216 | VStack(spacing: 20) {
217 | Image(systemName: "checkmark.circle.fill")
218 | .font(.system(size: 80))
219 | .foregroundColor(.green)
220 |
221 | Text("Expense Added!")
222 | .font(.title2)
223 | .fontWeight(.bold)
224 | .foregroundColor(.white)
225 | }
226 | .padding(30)
227 | .background(
228 | RoundedRectangle(cornerRadius: 20)
229 | .fill(Color(.systemBackground).opacity(0.8))
230 | .blur(radius: 0.5)
231 | )
232 | .scaleEffect(animateSuccess ? 1.0 : 0.5)
233 | .opacity(animateSuccess ? 1.0 : 0)
234 | .animation(.spring(), value: animateSuccess)
235 | }
236 | }
237 |
238 | // MARK: - Helper Methods
239 |
240 | private func isFormValid() -> Bool {
241 | return !title.isEmpty && !price.isEmpty
242 | }
243 |
244 | private func saveExpense() {
245 | // Hide keyboard first
246 | hideKeyboard()
247 |
248 | // Validate inputs
249 | if title.isEmpty {
250 | showValidationAlert("Please enter a title for your expense.")
251 | return
252 | }
253 |
254 | if price.isEmpty {
255 | showValidationAlert("Please enter the expense amount.")
256 | return
257 | }
258 |
259 | price = price.replacingOccurrences(of: ",", with: ".")
260 | guard let priceValue = Double(price) else {
261 | showValidationAlert("Please enter a valid amount.")
262 | return
263 | }
264 |
265 | // Show success animation
266 | withAnimation {
267 | animateSuccess = true
268 | }
269 |
270 | // Add the expense with all fields
271 | let newExpense = viewModel.addExpense(
272 | title: title,
273 | price: priceValue,
274 | date: selectedDate,
275 | category: selectedCategory
276 | )
277 |
278 | // Save notes to UserDefaults using the expense ID
279 | if !notes.isEmpty {
280 | let notesKey = "notes_\(newExpense.id.uuidString)"
281 | print("DEBUG: Saving notes for new expense: \(newExpense.id.uuidString)")
282 | print("DEBUG: Notes content: \"\(notes)\"")
283 | UserDefaults.standard.set(notes, forKey: notesKey)
284 | UserDefaults.standard.synchronize()
285 | }
286 |
287 | // Trigger success haptic
288 | HapticFeedback.success()
289 |
290 | // Wait for animation, then dismiss
291 | DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
292 | dismiss()
293 | }
294 | }
295 |
296 | private func showValidationAlert(_ message: String) {
297 | validationMessage = message
298 | showingValidationAlert = true
299 | HapticFeedback.error()
300 | }
301 |
302 | private func hideKeyboard() {
303 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
304 | }
305 |
306 | private func setupKeyboardObservers() {
307 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in
308 | if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
309 | self.keyboardHeight = keyboardFrame.height
310 | withAnimation {
311 | self.keyboardVisible = true
312 | }
313 | }
314 | }
315 |
316 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
317 | self.keyboardHeight = 0
318 | withAnimation {
319 | self.keyboardVisible = false
320 | }
321 | }
322 | }
323 |
324 | private func removeKeyboardObservers() {
325 | NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
326 | NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
327 | }
328 | }
329 |
330 | // MARK: - Locale Extension
331 |
332 | extension Locale {
333 | func localizedCurrencySymbol(forCurrencyCode currencyCode: String) -> String? {
334 | let identifier = NSLocale(localeIdentifier: self.identifier).displayName(forKey: .currencySymbol, value: currencyCode)
335 | return identifier
336 | }
337 | }
338 |
339 | #Preview {
340 | AddExpenseView(viewModel: ExpenseViewModel())
341 | }
342 |
--------------------------------------------------------------------------------
/iExpense/Components/Analytics/MonthlyTrendsView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MonthlyTrendView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 | import Charts
10 |
11 | /// Use the MonthlyTrend data structure from AnalyticsViewModel
12 | extension MonthlyTrend: Identifiable {
13 | public var id: String { "\(month)-\(year)" }
14 | }
15 |
16 | /// A component that displays monthly spending trends
17 | struct MonthlyTrendsView: View {
18 | let monthlyTrends: [MonthlyTrend]
19 |
20 | var body: some View {
21 | VStack(alignment: .leading, spacing: 12) {
22 | Text("Monthly Spending")
23 | .font(.headline)
24 |
25 | if monthlyTrends.isEmpty {
26 | Text("No data available")
27 | .foregroundColor(.secondary)
28 | .padding()
29 | .frame(maxWidth: .infinity, alignment: .center)
30 | } else {
31 | // Wrap the Chart in a GeometryReader to control its size precisely
32 | GeometryReader { geometry in
33 | Chart {
34 | ForEach(monthlyTrends) { trend in
35 | LineMark(
36 | x: .value("Month", trend.shortMonthName),
37 | y: .value("Amount", trend.amount)
38 | )
39 | .foregroundStyle(Color.blue.gradient)
40 | .symbol {
41 | Circle()
42 | .fill(Color.blue)
43 | .frame(width: 7, height: 7)
44 | }
45 | .interpolationMethod(.catmullRom)
46 |
47 | AreaMark(
48 | x: .value("Month", trend.shortMonthName),
49 | y: .value("Amount", trend.amount)
50 | )
51 | .foregroundStyle(
52 | .linearGradient(
53 | colors: [.blue.opacity(0.3), .blue.opacity(0.0)],
54 | startPoint: .top,
55 | endPoint: .bottom
56 | )
57 | )
58 | .interpolationMethod(.catmullRom)
59 | }
60 | }
61 | .chartYAxis {
62 | AxisMarks(position: .leading)
63 | }
64 | // Disable chart gestures
65 | .allowsHitTesting(false)
66 | // Fill the geometry reader
67 | .frame(width: geometry.size.width, height: 180)
68 | }
69 | .frame(height: 180)
70 | .fixedSize(horizontal: false, vertical: true)
71 |
72 | if let firstTrend = monthlyTrends.first,
73 | let lastTrend = monthlyTrends.last,
74 | firstTrend.amount > 0, lastTrend.amount > 0 {
75 | let percentChange = ((lastTrend.amount - firstTrend.amount) / firstTrend.amount) * 100
76 | HStack {
77 | Image(systemName: percentChange >= 0 ? "arrow.up.right" : "arrow.down.right")
78 | .foregroundColor(percentChange >= 0 ? .red : .green)
79 |
80 | Text("\(abs(Int(percentChange)))% \(percentChange >= 0 ? "increase" : "decrease") over \(monthlyTrends.count) months")
81 | .font(.caption)
82 | .foregroundColor(.secondary)
83 | }
84 | .padding(.top, 8)
85 | }
86 | }
87 | }
88 | .padding()
89 | .background(
90 | RoundedRectangle(cornerRadius: 12)
91 | .fill(Color(.secondarySystemBackground))
92 | )
93 | // Prevent gesture interference
94 | .contentShape(Rectangle())
95 | }
96 | }
97 |
98 | /// A component that displays category trend information
99 | struct CategoryTrendsView: View {
100 | struct CategoryTrend: Identifiable {
101 | var id: String { "\(category.rawValue)-\(month)-\(year)" }
102 | let category: Category
103 | let month: Int
104 | let year: Int
105 | let currentAmount: Double
106 | let previousAmount: Double
107 | var percentChange: Double {
108 | if previousAmount == 0 { return 0 }
109 | return ((currentAmount - previousAmount) / previousAmount) * 100
110 | }
111 | var isIncreasing: Bool {
112 | return currentAmount > previousAmount
113 | }
114 | }
115 |
116 | let categoryTrends: [CategoryTrend]
117 | let currencyCode: String
118 |
119 | init(
120 | categoryTrends: [CategoryTrend],
121 | currencyCode: String? = nil
122 | ) {
123 | self.categoryTrends = categoryTrends
124 | self.currencyCode = currencyCode ?? SettingsViewModel.getAppCurrency()
125 | }
126 |
127 | var body: some View {
128 | VStack(alignment: .leading, spacing: 12) {
129 | Text("Category Changes")
130 | .font(.headline)
131 |
132 | if categoryTrends.isEmpty {
133 | Text("No data available for comparison")
134 | .foregroundColor(.secondary)
135 | .padding()
136 | .frame(maxWidth: .infinity, alignment: .center)
137 | } else {
138 | let sortedTrends = categoryTrends
139 | .filter { $0.previousAmount > 0 } // Only categories with previous month data
140 | .sorted { abs($0.percentChange) > abs($1.percentChange) } // Sort by absolute percent change
141 | .prefix(4) // Top 4 changes
142 |
143 | VStack(spacing: 10) {
144 | ForEach(Array(sortedTrends)) { trend in
145 | categoryTrendRow(trend: trend)
146 | }
147 | }
148 | }
149 | }
150 | .padding()
151 | .background(
152 | RoundedRectangle(cornerRadius: 12)
153 | .fill(Color(.secondarySystemBackground))
154 | )
155 | }
156 |
157 | private func categoryTrendRow(trend: CategoryTrend) -> some View {
158 | HStack {
159 | // Category color and name
160 | Circle()
161 | .fill(trend.category.color)
162 | .frame(width: 12, height: 12)
163 |
164 | Text(trend.category.displayName)
165 | .font(.subheadline)
166 |
167 | Spacer()
168 |
169 | // Trend indicator and percentage
170 | HStack(spacing: 4) {
171 | Image(systemName: trend.isIncreasing ? "arrow.up" : "arrow.down")
172 | .foregroundColor(trend.isIncreasing ? .red : .green)
173 |
174 | Text("\(Int(abs(trend.percentChange)))%")
175 | .font(.caption)
176 | .fontWeight(.semibold)
177 | .foregroundColor(trend.isIncreasing ? .red : .green)
178 | }
179 |
180 | // Amounts
181 | VStack(alignment: .trailing) {
182 | Text(trend.currentAmount, format: .currency(code: currencyCode))
183 | .font(.caption)
184 |
185 | Text(trend.previousAmount, format: .currency(code: currencyCode))
186 | .font(.caption2)
187 | .foregroundColor(.secondary)
188 | }
189 | }
190 | }
191 | }
192 |
193 | /// A component that displays spending projections
194 | struct ProjectionView: View {
195 | let projectedMonthlySpend: Double
196 | let currentBudget: Double
197 | let currencyCode: String
198 |
199 | init(
200 | projectedMonthlySpend: Double,
201 | currentBudget: Double,
202 | currencyCode: String? = nil
203 | ) {
204 | self.projectedMonthlySpend = projectedMonthlySpend
205 | self.currentBudget = currentBudget
206 | self.currencyCode = currencyCode ?? SettingsViewModel.getAppCurrency()
207 | }
208 |
209 | var body: some View {
210 | VStack(alignment: .leading, spacing: 12) {
211 | Text("Monthly Projection")
212 | .font(.headline)
213 |
214 | HStack(alignment: .top, spacing: 16) {
215 | VStack(alignment: .leading, spacing: 8) {
216 | Text("Projected")
217 | .font(.subheadline)
218 | .foregroundColor(.secondary)
219 |
220 | Text(projectedMonthlySpend, format: .currency(code: currencyCode))
221 | .font(.title3)
222 | .fontWeight(.bold)
223 | }
224 | .frame(maxWidth: .infinity, alignment: .leading)
225 |
226 | if currentBudget > 0 {
227 | Divider()
228 | .frame(height: 50)
229 |
230 | VStack(alignment: .leading, spacing: 8) {
231 | Text("Budget")
232 | .font(.subheadline)
233 | .foregroundColor(.secondary)
234 |
235 | Text(currentBudget, format: .currency(code: currencyCode))
236 | .font(.title3)
237 | .fontWeight(.bold)
238 | }
239 | .frame(maxWidth: .infinity, alignment: .leading)
240 |
241 | Divider()
242 | .frame(height: 50)
243 |
244 | VStack(alignment: .trailing, spacing: 8) {
245 | Text("Difference")
246 | .font(.subheadline)
247 | .foregroundColor(.secondary)
248 |
249 | let difference = projectedMonthlySpend - currentBudget
250 | Text(abs(difference), format: .currency(code: currencyCode))
251 | .font(.title3)
252 | .fontWeight(.bold)
253 | .foregroundColor(difference > 0 ? .red : .green)
254 | }
255 | .frame(maxWidth: .infinity, alignment: .trailing)
256 | }
257 | }
258 |
259 | if currentBudget > 0 {
260 | let isOverBudget = projectedMonthlySpend > currentBudget
261 |
262 | Text(isOverBudget ? "You are projected to exceed your budget" : "You are projected to stay under budget")
263 | .font(.subheadline)
264 | .foregroundColor(isOverBudget ? .red : .green)
265 | .padding(.top, 4)
266 | }
267 | }
268 | .padding()
269 | .background(
270 | RoundedRectangle(cornerRadius: 12)
271 | .fill(Color(.secondarySystemBackground))
272 | )
273 | }
274 | }
275 |
276 | #Preview {
277 | VStack(spacing: 20) {
278 | // Monthly trends preview
279 | let trendData = (1...6).map { month in
280 | MonthlyTrend(
281 | month: month,
282 | year: 2025,
283 | amount: Double.random(in: 1500...3000)
284 | )
285 | }
286 |
287 | MonthlyTrendsView(monthlyTrends: trendData)
288 | .frame(height: 250)
289 |
290 | // Category trends preview
291 | let categoryTrends = [
292 | CategoryTrendsView.CategoryTrend(
293 | category: .food,
294 | month: 5,
295 | year: 2025,
296 | currentAmount: 450.50,
297 | previousAmount: 380.25
298 | ),
299 | CategoryTrendsView.CategoryTrend(
300 | category: .transportation,
301 | month: 5,
302 | year: 2025,
303 | currentAmount: 220.75,
304 | previousAmount: 280.50
305 | ),
306 | CategoryTrendsView.CategoryTrend(
307 | category: .entertainment,
308 | month: 5,
309 | year: 2025,
310 | currentAmount: 180.25,
311 | previousAmount: 120.75
312 | )
313 | ]
314 |
315 | CategoryTrendsView(categoryTrends: categoryTrends)
316 |
317 | // Projection preview
318 | ProjectionView(
319 | projectedMonthlySpend: 2850.50,
320 | currentBudget: 3000.00
321 | )
322 | }
323 | .padding()
324 | }
325 |
--------------------------------------------------------------------------------
/iExpense/Views/EditExpenseView.swift:
--------------------------------------------------------------------------------
1 | //
2 | // EditExpenseView.swift
3 | // iExpense
4 | //
5 | // Created by Dragomir Mindrescu on 27.04.2025.
6 | //
7 |
8 | import SwiftUI
9 |
10 | struct EditExpenseView: View {
11 | @Environment(\.dismiss) var dismiss
12 | @Environment(\.colorScheme) var colorScheme
13 | @ObservedObject var viewModel: ExpenseViewModel
14 |
15 | @State private var title: String
16 | @State private var price: String
17 | @State private var selectedDate: Date
18 | @State private var selectedCategory: Category
19 | @State private var showDatePicker: Bool = false
20 | @State private var notes: String = ""
21 | @State private var keyboardHeight: CGFloat = 0
22 | @State private var keyboardVisible: Bool = false
23 | @State private var viewID = UUID()
24 | let expense: Expense
25 |
26 | init(viewModel: ExpenseViewModel, expense: Expense) {
27 | print("DEBUG: EditExpenseView init for expense ID \(expense.id.uuidString)")
28 | self.viewModel = viewModel
29 | self.expense = expense
30 | _title = State(initialValue: expense.title)
31 | _price = State(initialValue: String(format: "%.2f", expense.price))
32 | _selectedDate = State(initialValue: expense.date)
33 | _selectedCategory = State(initialValue: expense.category)
34 |
35 | // Load notes directly from UserDefaults using expense ID as key
36 | let notesKey = "notes_\(expense.id.uuidString)"
37 | // List all the keys in UserDefaults to debug
38 | print("DEBUG: ALL USERDEFAULTS KEYS:")
39 | for key in UserDefaults.standard.dictionaryRepresentation().keys {
40 | if key.starts(with: "notes_") {
41 | let value = UserDefaults.standard.string(forKey: key) ?? "nil"
42 | print("DEBUG: - \(key) = \"\(value)\"")
43 | }
44 | }
45 |
46 | if let savedNotes = UserDefaults.standard.string(forKey: notesKey) {
47 | print("DEBUG: Init - Found notes for key \(notesKey): \"\(savedNotes)\"")
48 | _notes = State(initialValue: savedNotes)
49 | } else {
50 | print("DEBUG: Init - No notes found for key \(notesKey)")
51 | _notes = State(initialValue: "")
52 | }
53 | }
54 |
55 | // Current currency symbol
56 | private var currencySymbol: String {
57 | let locale = Locale.current
58 | let currencyCode = SettingsViewModel.getAppCurrency()
59 | return locale.localizedCurrencySymbol(forCurrencyCode: currencyCode) ?? currencyCode
60 | }
61 |
62 | var body: some View {
63 | NavigationView {
64 | ZStack {
65 | Color(.systemGroupedBackground)
66 | .ignoresSafeArea()
67 |
68 | ScrollView {
69 | VStack(spacing: 20) {
70 | // Title and price card
71 | CardView(title: "Expense Details", showDivider: true) {
72 | VStack(spacing: 16) {
73 | TextFormField(
74 | label: "Title",
75 | text: $title,
76 | placeholder: "Expense title"
77 | )
78 | .padding(.horizontal)
79 |
80 | CurrencyFormField(
81 | label: "Amount",
82 | amount: $price,
83 | currencySymbol: currencySymbol
84 | )
85 | .padding(.horizontal)
86 | .padding(.bottom, 8)
87 | }
88 | }
89 |
90 | // Date picker
91 | DatePickerCard(
92 | title: "Date",
93 | selectedDate: $selectedDate,
94 | isExpanded: $showDatePicker
95 | )
96 |
97 | // Category selection
98 | CardView(title: "Category") {
99 | CategoryGrid(selectedCategory: $selectedCategory)
100 | .padding(.horizontal)
101 | }
102 |
103 | // Notes section with improved appearance
104 | CardView(title: "Notes (Optional)") {
105 | ZStack(alignment: .topLeading) {
106 | // Background that adapts to color scheme
107 | RoundedRectangle(cornerRadius: 10)
108 | .fill(colorScheme == .dark ? Color(.systemGray6) : Color(.systemBackground))
109 | .frame(minHeight: 100)
110 |
111 | // Text editor
112 | TextEditor(text: $notes)
113 | .font(.body)
114 | .scrollContentBackground(.hidden) // Hide the default background
115 | .background(Color.clear) // Use transparent background
116 | .padding(8)
117 | .frame(minHeight: 100)
118 |
119 | // Display notes length for debugging
120 | Text("Notes length: \(notes.count)")
121 | .font(.caption2)
122 | .foregroundColor(.secondary)
123 | .padding(4)
124 | .background(Color(.systemBackground).opacity(0.7))
125 | .cornerRadius(4)
126 | .padding(8)
127 | .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottomTrailing)
128 | }
129 | .padding(.horizontal)
130 | .padding(.bottom, 8)
131 | }
132 |
133 | // Action buttons
134 | VStack(spacing: 12) {
135 | Button(action: {
136 | hideKeyboard()
137 | saveChanges()
138 | HapticFeedback.success()
139 | dismiss()
140 | }) {
141 | let saveButton: some View = HStack {
142 | Image(systemName: "checkmark.circle.fill")
143 | Text("Save Changes")
144 | }
145 | .frame(maxWidth: .infinity)
146 | .padding(.vertical, 16)
147 | .foregroundColor(.white)
148 | .cornerRadius(12)
149 |
150 | if #available(iOS 26.0, *) {
151 | saveButton
152 | .glassEffect(.regular.tint(.blue).interactive())
153 | } else {
154 | saveButton
155 | .background(Color.blue.opacity(0.8))
156 | .cornerRadius(12)
157 | }
158 | }
159 |
160 | Button(action: {
161 | hideKeyboard()
162 | deleteExpense()
163 | HapticFeedback.impact(style: .medium)
164 | dismiss()
165 | }) {
166 | let deleteButton: some View = HStack {
167 | Image(systemName: "trash.fill")
168 | Text("Delete Expense")
169 | }
170 | .frame(maxWidth: .infinity)
171 | .padding(.vertical, 16)
172 | .foregroundColor(.white)
173 | .cornerRadius(12)
174 | if #available(iOS 26.0, *) {
175 | deleteButton
176 | .glassEffect(.regular.tint(.red).interactive())
177 | } else {
178 | deleteButton
179 | .background(Color.red.opacity(0.8))
180 | .cornerRadius(12)
181 | }
182 | }
183 | }
184 | .padding(.top, 10)
185 | }
186 | .padding()
187 | .padding(.bottom, keyboardHeight > 0 ? keyboardHeight - 40 : 20)
188 | }
189 | }
190 | .id(viewID)
191 | .navigationTitle("Edit Expense")
192 | .navigationBarTitleDisplayMode(.inline)
193 | .toolbar {
194 | // Cancel button
195 | ToolbarItem(placement: .navigationBarLeading) {
196 | Button("Cancel") {
197 | dismiss()
198 | }
199 | }
200 |
201 | // Done button only shows when keyboard is visible
202 | ToolbarItem(placement: .navigationBarTrailing) {
203 | if keyboardVisible {
204 | Button("Done") {
205 | hideKeyboard()
206 | }
207 | }
208 | }
209 | }
210 | .onAppear {
211 | viewID = UUID()
212 |
213 | setupKeyboardObservers()
214 | print("DEBUG: EditExpenseView appeared with notes: \"\(notes)\"")
215 |
216 | // Double check notes loading on appear
217 | let notesKey = "notes_\(expense.id.uuidString)"
218 | if let savedNotes = UserDefaults.standard.string(forKey: notesKey) {
219 | print("DEBUG: onAppear - Found notes for key \(notesKey): \"\(savedNotes)\"")
220 | // Force update notes if there's a mismatch
221 | if notes != savedNotes {
222 | notes = savedNotes
223 | print("DEBUG: onAppear - Updated notes to \"\(notes)\"")
224 | }
225 | } else {
226 | print("DEBUG: onAppear - No notes found for key \(notesKey)")
227 | }
228 | }
229 | .onDisappear {
230 | removeKeyboardObservers()
231 | }
232 | }
233 | }
234 |
235 | private func hideKeyboard() {
236 | UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
237 | }
238 |
239 | private func saveChanges() {
240 | print("DEBUG: Saving changes for expense ID \(expense.id.uuidString)")
241 | guard let priceValue = Double(price.replacingOccurrences(of: ",", with: ".")) else { return }
242 |
243 | if let index = viewModel.expenses.firstIndex(where: { $0.id == expense.id }) {
244 | viewModel.expenses[index].title = title
245 | viewModel.expenses[index].price = priceValue
246 | viewModel.expenses[index].date = selectedDate
247 | viewModel.expenses[index].category = selectedCategory
248 | viewModel.saveExpenses()
249 |
250 | // Save notes to UserDefaults with expense ID as key
251 | let notesKey = "notes_\(expense.id.uuidString)"
252 | UserDefaults.standard.set(notes, forKey: notesKey)
253 | UserDefaults.standard.synchronize()
254 | print("DEBUG: Saved notes for key \(notesKey): \"\(notes)\"")
255 | }
256 | }
257 |
258 | private func deleteExpense() {
259 | print("DEBUG: Deleting expense ID \(expense.id.uuidString)")
260 | if let index = viewModel.expenses.firstIndex(where: { $0.id == expense.id }) {
261 | viewModel.expenses.remove(at: index)
262 | viewModel.saveExpenses()
263 |
264 | // Remove notes from UserDefaults when expense is deleted
265 | let notesKey = "notes_\(expense.id.uuidString)"
266 | UserDefaults.standard.removeObject(forKey: notesKey)
267 | UserDefaults.standard.synchronize()
268 | print("DEBUG: Removed notes for key \(notesKey)")
269 | }
270 | }
271 |
272 | // MARK: - Keyboard Handling
273 |
274 | private func setupKeyboardObservers() {
275 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillShowNotification, object: nil, queue: .main) { notification in
276 | if let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect {
277 | self.keyboardHeight = keyboardFrame.height
278 | withAnimation {
279 | self.keyboardVisible = true
280 | }
281 | }
282 | }
283 |
284 | NotificationCenter.default.addObserver(forName: UIResponder.keyboardWillHideNotification, object: nil, queue: .main) { _ in
285 | self.keyboardHeight = 0
286 | withAnimation {
287 | self.keyboardVisible = false
288 | }
289 | }
290 | }
291 |
292 | private func removeKeyboardObservers() {
293 | NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillShowNotification, object: nil)
294 | NotificationCenter.default.removeObserver(self, name: UIResponder.keyboardWillHideNotification, object: nil)
295 | }
296 | }
297 |
298 | #Preview {
299 | EditExpenseView(viewModel: ExpenseViewModel(), expense: Expense(title: "Sample", price: 10, date: Date(), category: .food))
300 | }
301 |
--------------------------------------------------------------------------------
/iExpenseWidgetExtension/iExpenseWidgetExtension.swift:
--------------------------------------------------------------------------------
1 | // iExpenseWidgetExtension.swift
2 | // iExpenseWidgetExtension
3 |
4 | import WidgetKit
5 | import SwiftUI
6 | import AppIntents
7 | import Foundation
8 |
9 | // Global app group ID for consistency - must match StorageService.appGroupID
10 | let appGroupID = "group.com.vintuss.Inpenso"
11 |
12 | // Global function to get currency code from shared UserDefaults
13 | func getAppCurrency() -> String {
14 | // First try to get from shared defaults with explicit initialization
15 | let sharedDefaults = UserDefaults(suiteName: appGroupID)
16 |
17 | if let sharedDefaults = sharedDefaults {
18 | // Force a synchronize before reading
19 | sharedDefaults.synchronize()
20 |
21 | // Try to read the currency directly
22 | if let currency = sharedDefaults.string(forKey: "selectedCurrency") {
23 | return currency
24 | }
25 | }
26 |
27 | // If we got here, try standard UserDefaults
28 | let standardDefaults = UserDefaults.standard
29 | standardDefaults.synchronize()
30 | let standardCurrency = standardDefaults.string(forKey: "selectedCurrency")
31 |
32 | // If all else fails, return USD
33 | return standardCurrency ?? "USD"
34 | }
35 |
36 | // Get monthly budget from shared UserDefaults
37 | func getMonthlyBudget() -> Double {
38 | let sharedDefaults = UserDefaults(suiteName: appGroupID)
39 |
40 | if let sharedDefaults = sharedDefaults {
41 | // Force a synchronize before reading
42 | sharedDefaults.synchronize()
43 |
44 | // Get the budgets data
45 | if let budgetsData = sharedDefaults.data(forKey: "budgets") {
46 | do {
47 | let budgets = try JSONDecoder().decode([String: Double].self, from: budgetsData)
48 |
49 | // Get current month in format "MM-YYYY"
50 | let dateFormatter = DateFormatter()
51 | dateFormatter.dateFormat = "MM-yyyy"
52 | let currentMonthKey = dateFormatter.string(from: Date())
53 |
54 | // Return the budget for the current month
55 | return budgets[currentMonthKey] ?? 0
56 | } catch {
57 | return 0
58 | }
59 | }
60 | }
61 |
62 | return 0
63 | }
64 |
65 | struct ExpenseEntry: TimelineEntry {
66 | let date: Date
67 | let totalSpent: Double
68 | let spendingByCategory: [Category: Double]
69 | let monthlyBudget: Double
70 |
71 | // Computed properties for the widget
72 | var budgetRemaining: Double {
73 | max(0, monthlyBudget - totalSpent)
74 | }
75 |
76 | var budgetProgress: Double {
77 | monthlyBudget > 0 ? min(1.0, totalSpent / monthlyBudget) : 0
78 | }
79 |
80 | var topCategories: [(Category, Double)] {
81 | Array(spendingByCategory.sorted { $0.value > $1.value }.prefix(5))
82 | }
83 |
84 | var overBudget: Bool {
85 | monthlyBudget > 0 && totalSpent > monthlyBudget
86 | }
87 |
88 | var daysLeftInMonth: Int {
89 | let calendar = Calendar.current
90 | let today = calendar.component(.day, from: Date())
91 |
92 | // Get range of days in current month
93 | let range = calendar.range(of: .day, in: .month, for: Date())!
94 | let daysInMonth = range.count
95 |
96 | return daysInMonth - today
97 | }
98 |
99 | var dailyBudgetRecommendation: Double {
100 | if daysLeftInMonth > 0 && monthlyBudget > 0 {
101 | return budgetRemaining / Double(daysLeftInMonth)
102 | }
103 | return 0
104 | }
105 | }
106 |
107 | struct ExpenseQuickAddProvider: AppIntentTimelineProvider {
108 | typealias Intent = QuickAddConfigurationIntent
109 |
110 | // Use the shared app group
111 | private let sharedDefaults = UserDefaults(suiteName: appGroupID)
112 |
113 | func placeholder(in context: Context) -> ExpenseEntry {
114 | ExpenseEntry(
115 | date: Date(),
116 | totalSpent: 0,
117 | spendingByCategory: [:],
118 | monthlyBudget: 0
119 | )
120 | }
121 |
122 | func snapshot(for configuration: QuickAddConfigurationIntent, in context: Context) async -> ExpenseEntry {
123 | await loadEntry()
124 | }
125 |
126 | func timeline(for configuration: QuickAddConfigurationIntent, in context: Context) async -> Timeline {
127 | let entry = await loadEntry()
128 | let timeline = Timeline(entries: [entry], policy: .after(Date().addingTimeInterval(1800))) // refresh every 30min
129 | return timeline
130 | }
131 |
132 | private func loadEntry() async -> ExpenseEntry {
133 | let expenses = StorageService.loadExpenses()
134 | let monthlyBudget = getMonthlyBudget()
135 |
136 | let calendar = Calendar.current
137 | let currentMonth = calendar.component(.month, from: Date())
138 | let currentYear = calendar.component(.year, from: Date())
139 |
140 | let filteredExpenses = expenses.filter { expense in
141 | let month = calendar.component(.month, from: expense.date)
142 | let year = calendar.component(.year, from: expense.date)
143 | return month == currentMonth && year == currentYear
144 | }
145 |
146 | let total = filteredExpenses.reduce(0) { $0 + $1.price }
147 |
148 | var categoryTotals: [Category: Double] = [:]
149 | for expense in filteredExpenses {
150 | categoryTotals[expense.category, default: 0] += expense.price
151 | }
152 |
153 | return ExpenseEntry(
154 | date: Date(),
155 | totalSpent: total,
156 | spendingByCategory: categoryTotals,
157 | monthlyBudget: monthlyBudget
158 | )
159 | }
160 | }
161 |
162 | // MARK: - Widget Views
163 | struct iExpenseWidgetEntryView: View {
164 | var entry: ExpenseEntry
165 | @Environment(\.widgetFamily) var family
166 | let currencyCode = getAppCurrency()
167 |
168 | var body: some View {
169 | switch family {
170 | case .systemSmall:
171 | smallWidget
172 | case .systemMedium:
173 | mediumWidget
174 | case .systemLarge:
175 | largeWidget
176 | default:
177 | smallWidget
178 | }
179 | }
180 |
181 | // MARK: - Small Widget (2x2)
182 | var smallWidget: some View {
183 | ZStack {
184 | VStack(alignment: .center, spacing: 4) {
185 | // "Monthly Spend" title with icon
186 | HStack(spacing: 4) {
187 | Image(systemName: "dollarsign.circle.fill")
188 | .foregroundColor(entry.overBudget ? .red : .blue)
189 | .font(.system(size: 14))
190 |
191 | Text("MONTHLY SPEND")
192 | .font(.system(size: 10, weight: .semibold))
193 | .foregroundColor(.secondary)
194 | .kerning(0.5)
195 | }
196 | .padding(.top, 4)
197 |
198 | Spacer()
199 |
200 | // Amount in large, attractive font
201 | Text(entry.totalSpent, format: .currency(code: currencyCode))
202 | .font(.system(.title2, design: .rounded))
203 | .fontWeight(.bold)
204 | .lineLimit(1)
205 | .minimumScaleFactor(0.6)
206 | .foregroundColor(entry.overBudget ? .red : .primary)
207 | .shadow(color: Color.black.opacity(0.05), radius: 1, x: 0, y: 1)
208 |
209 | // Budget indicator if budget exists
210 | if entry.monthlyBudget > 0 {
211 | HStack(spacing: 4) {
212 | Circle()
213 | .fill(entry.overBudget ? .red : .green)
214 | .frame(width: 6, height: 6)
215 |
216 | Text(entry.overBudget ? "Over Budget" : "\(Int(entry.budgetProgress * 100))% of Budget")
217 | .font(.system(size: 9, weight: .medium))
218 | .foregroundColor(entry.overBudget ? .red : .green)
219 | }
220 | .padding(.bottom, 2)
221 | } else {
222 | Text("This Month")
223 | .font(.system(size: 10))
224 | .foregroundColor(.secondary)
225 | .padding(.bottom, 2)
226 | }
227 |
228 | Spacer()
229 | }
230 | .padding(.horizontal, 10)
231 | .padding(.vertical, 8)
232 | }
233 | }
234 |
235 | // MARK: - Medium Widget
236 | var mediumWidget: some View {
237 | HStack(spacing: 16) {
238 | // Left section - Monthly spending amount
239 | VStack(alignment: .leading, spacing: 4) {
240 | Text(entry.totalSpent, format: .currency(code: currencyCode))
241 | .font(.system(.title, design: .rounded))
242 | .fontWeight(.bold)
243 | .lineLimit(1)
244 | .minimumScaleFactor(0.7)
245 | .foregroundColor(entry.overBudget ? .red : .primary)
246 |
247 | Text("spent this month")
248 | .font(.caption)
249 | .foregroundColor(.secondary)
250 | }
251 | .frame(maxWidth: .infinity, alignment: .leading)
252 |
253 | // Right section - Budget circle if available
254 | if entry.monthlyBudget > 0 {
255 | ZStack {
256 | // Background circle
257 | Circle()
258 | .stroke(Color(.systemGray5), lineWidth: 8)
259 | .frame(width: 80, height: 80)
260 |
261 | // Progress circle
262 | Circle()
263 | .trim(from: 0, to: entry.budgetProgress)
264 | .stroke(
265 | entry.overBudget ? Color.red : Color.green,
266 | style: StrokeStyle(lineWidth: 8, lineCap: .round)
267 | )
268 | .frame(width: 80, height: 80)
269 | .rotationEffect(.degrees(-90))
270 |
271 | // Percentage text
272 | VStack(spacing: 0) {
273 | Text("\(Int(entry.budgetProgress * 100))%")
274 | .font(.system(.body, design: .rounded))
275 | .fontWeight(.bold)
276 | .foregroundColor(entry.overBudget ? .red : .green)
277 |
278 | Text("of budget")
279 | .font(.caption2)
280 | .foregroundColor(.secondary)
281 | }
282 | }
283 | .frame(width: 100, height: 100)
284 | }
285 | }
286 | .padding(16)
287 | }
288 |
289 | // MARK: - Large Widget
290 | var largeWidget: some View {
291 | VStack {
292 | // Top - Monthly spending
293 | Text(entry.totalSpent, format: .currency(code: currencyCode))
294 | .font(.system(.largeTitle, design: .rounded))
295 | .fontWeight(.bold)
296 | .lineLimit(1)
297 | .minimumScaleFactor(0.8)
298 | .foregroundColor(entry.overBudget ? .red : .primary)
299 |
300 | Text("spent this month")
301 | .font(.headline)
302 | .foregroundColor(.secondary)
303 |
304 | // Budget visualization if available
305 | if entry.monthlyBudget > 0 {
306 | Spacer()
307 |
308 | ZStack {
309 | // Background circle
310 | Circle()
311 | .stroke(Color(.systemGray5), lineWidth: 15)
312 | .frame(width: 180, height: 180)
313 |
314 | // Progress circle
315 | Circle()
316 | .trim(from: 0, to: entry.budgetProgress)
317 | .stroke(
318 | entry.overBudget ? Color.red : Color.green,
319 | style: StrokeStyle(lineWidth: 15, lineCap: .round)
320 | )
321 | .frame(width: 180, height: 180)
322 | .rotationEffect(.degrees(-90))
323 |
324 | // Inner information
325 | VStack(spacing: 4) {
326 | Text(entry.overBudget ? "OVER BUDGET" : "BUDGET")
327 | .font(.caption)
328 | .fontWeight(.semibold)
329 | .foregroundColor(.secondary)
330 |
331 | if entry.overBudget {
332 | Text(entry.totalSpent - entry.monthlyBudget, format: .currency(code: currencyCode))
333 | .font(.system(.title3, design: .rounded))
334 | .fontWeight(.bold)
335 | .foregroundColor(.red)
336 | } else {
337 | Text(entry.budgetRemaining, format: .currency(code: currencyCode))
338 | .font(.system(.title3, design: .rounded))
339 | .fontWeight(.bold)
340 | .foregroundColor(.green)
341 | }
342 |
343 | Text("\(Int(entry.budgetProgress * 100))% used")
344 | .font(.subheadline)
345 | .foregroundColor(.secondary)
346 | }
347 | }
348 |
349 | Spacer()
350 |
351 | Text("\(entry.daysLeftInMonth) days left in the month")
352 | .font(.subheadline)
353 | .foregroundColor(.secondary)
354 | } else {
355 | Spacer()
356 |
357 | // If no budget, show a message
358 | VStack {
359 | Image(systemName: "chart.line.uptrend.xyaxis")
360 | .font(.system(size: 50))
361 | .foregroundColor(.secondary)
362 | .padding(.bottom, 10)
363 |
364 | Text("No budget set for this month")
365 | .font(.title3)
366 | .foregroundColor(.secondary)
367 | }
368 | .padding()
369 |
370 | Spacer()
371 | }
372 | }
373 | .padding(16)
374 | }
375 |
376 | // Helper function to get category icon
377 | private func categoryIcon(for category: Category) -> String {
378 | switch category {
379 | case .food:
380 | return "cart.fill"
381 | case .eatingOut:
382 | return "fork.knife"
383 | case .rent:
384 | return "house.fill"
385 | case .shopping:
386 | return "bag.fill"
387 | case .entertainment:
388 | return "tv.fill"
389 | case .transportation:
390 | return "car.fill"
391 | case .utilities:
392 | return "bolt.fill"
393 | case .subscriptions:
394 | return "repeat"
395 | case .healthcare:
396 | return "heart.fill"
397 | case .education:
398 | return "book.fill"
399 | case .others:
400 | return "ellipsis"
401 | }
402 | }
403 | }
404 |
405 | struct iExpenseWidgetExtension: Widget {
406 | let kind: String = "iExpenseWidgetExtension"
407 |
408 | var body: some WidgetConfiguration {
409 | AppIntentConfiguration(kind: kind, provider: ExpenseQuickAddProvider()) { entry in
410 | iExpenseWidgetEntryView(entry: entry)
411 | .containerBackground(.widgetBackground, for: .widget)
412 | }
413 | .supportedFamilies([.systemSmall, .systemMedium, .systemLarge])
414 | .configurationDisplayName("iExpense Tracker")
415 | .description("Track your monthly spending, budget progress, and top categories at a glance.")
416 | }
417 | }
418 |
419 | // MARK: - Widget Background Extension
420 | extension ShapeStyle where Self == Color {
421 | static var widgetBackground: Color {
422 | Color(.systemBackground)
423 | }
424 | }
425 |
426 | // MARK: - Previews
427 | #Preview(as: .systemSmall) {
428 | iExpenseWidgetExtension()
429 | } timeline: {
430 | ExpenseEntry(
431 | date: .now,
432 | totalSpent: 780.50,
433 | spendingByCategory: [
434 | .food: 250.00,
435 | .shopping: 175.75,
436 | .transportation: 80.25,
437 | .entertainment: 120.50,
438 | .utilities: 154.00
439 | ],
440 | monthlyBudget: 1000.00
441 | )
442 | }
443 |
444 | #Preview(as: .systemMedium) {
445 | iExpenseWidgetExtension()
446 | } timeline: {
447 | ExpenseEntry(
448 | date: .now,
449 | totalSpent: 780.50,
450 | spendingByCategory: [
451 | .food: 250.00,
452 | .shopping: 175.75,
453 | .transportation: 80.25,
454 | .entertainment: 120.50,
455 | .utilities: 154.00
456 | ],
457 | monthlyBudget: 1000.00
458 | )
459 | }
460 |
461 | #Preview(as: .systemLarge) {
462 | iExpenseWidgetExtension()
463 | } timeline: {
464 | ExpenseEntry(
465 | date: .now,
466 | totalSpent: 780.50,
467 | spendingByCategory: [
468 | .food: 250.00,
469 | .shopping: 175.75,
470 | .transportation: 80.25,
471 | .entertainment: 120.50,
472 | .utilities: 154.00
473 | ],
474 | monthlyBudget: 1000.00
475 | )
476 | }
477 |
478 |
--------------------------------------------------------------------------------