├── .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 | Inpenso Logo 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 | Inpenso Logo 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 |
DashboardAnalyticsAdd Expense
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 | --------------------------------------------------------------------------------