├── README └── Screenshots │ ├── GitHubCharts_v4.png │ ├── GitHubHeader_v4.png │ ├── GitHubCurrencies_v4.png │ └── GitHubFiltersAndExport_v4.png ├── financecontrol ├── Assets.xcassets │ ├── Contents.json │ ├── Nord │ │ ├── Contents.json │ │ ├── NordBlue.colorset │ │ │ └── Contents.json │ │ ├── NordBlueDark.colorset │ │ │ └── Contents.json │ │ ├── NordGreen.colorset │ │ │ └── Contents.json │ │ ├── NordRed.colorset │ │ │ └── Contents.json │ │ ├── NordRedDark.colorset │ │ │ └── Contents.json │ │ ├── NordTeal.colorset │ │ │ └── Contents.json │ │ ├── NordTealDark.colorset │ │ │ └── Contents.json │ │ ├── NordGreenDark.colorset │ │ │ └── Contents.json │ │ ├── NordOrange.colorset │ │ │ └── Contents.json │ │ ├── NordOrangeDark.colorset │ │ │ └── Contents.json │ │ ├── NordPurple.colorset │ │ │ └── Contents.json │ │ ├── NordPurpleDark.colorset │ │ │ └── Contents.json │ │ ├── NordYellow.colorset │ │ │ └── Contents.json │ │ └── NordYellowDark.colorset │ │ │ └── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ └── AppIcon_Image.imageset │ │ └── Contents.json ├── ViewModel │ ├── Protocols │ │ ├── ViewModelProtocol.swift │ │ └── SpendingViewModelProtocol.swift │ └── SearchViewModel.swift ├── View │ ├── Universal │ │ ├── ListHorizontalScroll │ │ │ └── ListHorizontalScrollRepresentable.swift │ │ ├── Shapes │ │ │ └── Line.swift │ │ ├── UniversalViews.swift │ │ ├── Custom Alert │ │ │ ├── CustomAlertViewExtension.swift │ │ │ ├── CustomAlertType.swift │ │ │ ├── CustomAlertManager.swift │ │ │ ├── CustomAlertViewModel.swift │ │ │ ├── CustomAlertData.swift │ │ │ └── CustomAlertView.swift │ │ └── CustomContentUnavailableView.swift │ ├── Stats │ │ ├── ViewModel │ │ │ ├── StatsViewModel.swift │ │ │ ├── StatsRowViewModel.swift │ │ │ ├── StatsSearchViewModel.swift │ │ │ └── PieChartViewModel.swift │ │ └── Views │ │ │ ├── PieChartCompleteView.swift │ │ │ ├── PieChartCenterView.swift │ │ │ ├── EditReturnViewModel.swift │ │ │ ├── Filters │ │ │ ├── FiltersReturnsView.swift │ │ │ ├── FiltersCurrenciesView.swift │ │ │ └── FiltersCategoriesView.swift │ │ │ ├── StatsListView.swift │ │ │ └── PieChartLegendView.swift │ ├── Settings │ │ ├── Views │ │ │ ├── CustomShareSheet.swift │ │ │ ├── Category │ │ │ │ ├── ShadowedCategoriesView.swift │ │ │ │ ├── ShadowedCategoriesRow.swift │ │ │ │ ├── CategoryRow.swift │ │ │ │ └── AddCategoryView.swift │ │ │ ├── Currency │ │ │ │ ├── NewCurrencyRow.swift │ │ │ │ ├── CurrencyRow.swift │ │ │ │ └── AddCurrencyView.swift │ │ │ ├── IconRow.swift │ │ │ ├── SettingsFormattingView.swift │ │ │ └── ColorAndIconView.swift │ │ └── ViewModel │ │ │ ├── ICloudSyncViewModel.swift │ │ │ └── IconRowViewModel.swift │ ├── Onboarding │ │ └── OnboardingHeaderView.swift │ ├── Home │ │ ├── Views │ │ │ └── BarChartBar.swift │ │ └── ViewModels │ │ │ └── BarChartViewModel.swift │ ├── Spending │ │ └── Views │ │ │ └── ReturnRow.swift │ └── Selectors │ │ └── CustomColorSelector.swift ├── Protocols │ └── ToSafeUnsafeObject.swift ├── financecontrolApp.swift ├── Data │ ├── SocialNetworkModel.swift │ ├── CoreData │ │ ├── Entities │ │ │ ├── ReturnEntity │ │ │ │ ├── ReturnEntity+CoreDataProperties.swift │ │ │ │ └── ReturnsModel.swift │ │ │ └── CategoryEntity │ │ │ │ ├── CategoryEntity+CoreDataClass.swift │ │ │ │ └── CategoriesModel.swift │ │ ├── DataManager.swift │ │ └── DataContainer.xcdatamodeld │ │ │ └── SpendingsModel.xcdatamodel │ │ │ └── contents │ └── Currency.swift ├── Utils │ ├── PrivacyMonitor.swift │ ├── Colors.swift │ ├── Formatters.swift │ ├── InputUtils.swift │ ├── LaunchActions.swift │ ├── CloudKitKVSManager.swift │ ├── HapticManager.swift │ ├── CustomIcon.swift │ ├── NetworkMonitor.swift │ ├── Keychain.swift │ └── WidgetsManager.swift ├── Extensions │ ├── AnyTransition+Extensions.swift │ ├── Transition+Extensions.swift │ ├── Color+Extensions.swift │ ├── UDKey.swift │ ├── Date+Extensions.swift │ ├── URL+Extensions.swift │ ├── NumbersViewModifier.swift │ ├── OtherExtensions.swift │ └── TimeZone+Extensions.swift ├── financecontrol.entitlements └── Error │ ├── CoreDataErrors.swift │ ├── RatesErrors.swift │ └── ErrorHandler.swift ├── financecontrolWidget ├── Assets.xcassets │ ├── Contents.json │ ├── AccentColor.colorset │ │ └── Contents.json │ ├── AppIcon.appiconset │ │ └── Contents.json │ ├── SquirrelLogoImage.imageset │ │ └── Contents.json │ ├── UpperWidgetColor.colorset │ │ └── Contents.json │ ├── WidgetBackground.colorset │ │ └── Contents.json │ └── BottomWidgetColor.colorset │ │ └── Contents.json ├── financecontrolWidgetExtension.entitlements ├── Widget │ ├── AccessorySumWidget.swift │ ├── AccessoryCircularAddExpenseWidget.swift │ ├── SmallSumWidget.swift │ └── WeeklySpendingsWidget.swift ├── financecontrolWidgetBundle.swift ├── View │ ├── AccessorySumWidgetView.swift │ ├── AccessoryCircularAddExpenseView.swift │ ├── AccessoryRectangularSumWidgetView.swift │ ├── AccessoryCircularSumWidgetView.swift │ ├── SumWidgetView.swift │ └── WeeklySpendingsTodaySumView.swift └── Timeline │ ├── AddExpenseWidgetProvider.swift │ └── SumWidgetTimelineProvider.swift ├── Squirrel.xcodeproj ├── project.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ ├── WorkspaceSettings.xcsettings │ │ ├── IDEWorkspaceChecks.plist │ │ └── swiftpm │ │ └── Package.resolved └── xcshareddata │ └── xcschemes │ ├── Squirrel_Release.xcscheme │ ├── Squirrel_RTL.xcscheme │ ├── Squirrel_Debug.xcscheme │ └── Squirrel_CoreDataDebug.xcscheme ├── PrivacyInfo.xcprivacy ├── Vars.swift ├── README.md ├── financecontrolTests ├── CoreDataTests │ └── CoreDataModelTests.swift └── FallbackRatesTests.swift └── .gitignore /README/Screenshots/GitHubCharts_v4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubCharts_v4.png -------------------------------------------------------------------------------- /README/Screenshots/GitHubHeader_v4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubHeader_v4.png -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README/Screenshots/GitHubCurrencies_v4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubCurrencies_v4.png -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /README/Screenshots/GitHubFiltersAndExport_v4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/PinkXaciD/Squirrel/HEAD/README/Screenshots/GitHubFiltersAndExport_v4.png -------------------------------------------------------------------------------- /Squirrel.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /financecontrolWidget/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 | -------------------------------------------------------------------------------- /financecontrol/ViewModel/Protocols/ViewModelProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ViewModelProtocol.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | typealias ViewModel = ObservableObject 11 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /financecontrol/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 | "properties" : { 12 | "localizable" : true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iOS_AppIcon_Default.png", 5 | "idiom" : "universal", 6 | "platform" : "ios", 7 | "size" : "1024x1024" 8 | } 9 | ], 10 | "info" : { 11 | "author" : "xcode", 12 | "version" : 1 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/ListHorizontalScroll/ListHorizontalScrollRepresentable.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ListHorizontalScrollRepresentable.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/03/25. 6 | // 7 | 8 | import SwiftUI 9 | 10 | protocol ListHorizontalScrollRepresentable { 11 | var label: Text { get } 12 | var foregroundColor: Color { get } 13 | } 14 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Shapes/Line.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Line.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/03. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct Line: Shape { 11 | func path(in rect: CGRect) -> Path { 12 | var path = Path() 13 | path.move(to: CGPoint(x: 0, y: 0)) 14 | path.addLine(to: CGPoint(x: rect.width, y: 0)) 15 | return path 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/ViewModel/StatsViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/04. 6 | // 7 | 8 | import Foundation 9 | 10 | final class StatsViewModel: ViewModel { 11 | @Published 12 | var entityToEdit: SpendingEntity? = nil 13 | @Published 14 | var entityToAddReturn: SpendingEntity? = nil 15 | @Published 16 | var edit: Bool = false 17 | 18 | init() {} 19 | } 20 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordBlue.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xC1", 9 | "green" : "0xA1", 10 | "red" : "0x81" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordBlueDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA4", 9 | "green" : "0x7A", 10 | "red" : "0x51" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordGreen.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.549", 9 | "green" : "0.745", 10 | "red" : "0.639" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordRed.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.416", 9 | "green" : "0.380", 10 | "red" : "0.749" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordRedDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x44", 9 | "green" : "0x3B", 10 | "red" : "0x91" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordTeal.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xBB", 9 | "green" : "0xBC", 10 | "red" : "0x8F" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordTealDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xA2", 9 | "green" : "0xA3", 10 | "red" : "0x66" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Protocols/ToSafeUnsafeObject.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ToSafeUnsafeObject.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/07. 6 | // 7 | 8 | import CoreData 9 | 10 | protocol ToSafeObject { 11 | associatedtype SafeType 12 | func safeObject() throws -> SafeType 13 | } 14 | 15 | protocol ToUnsafeObject { 16 | associatedtype UnsafeType: NSManagedObject 17 | func unsafeObject(in context: NSManagedObjectContext) throws -> UnsafeType 18 | } 19 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordGreenDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x62", 9 | "green" : "0xA7", 10 | "red" : "0x82" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordOrange.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.439", 9 | "green" : "0.529", 10 | "red" : "0.816" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordOrangeDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x3C", 9 | "green" : "0x59", 10 | "red" : "0xB9" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordPurple.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.678", 9 | "green" : "0.557", 10 | "red" : "0.706" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordPurpleDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x8A", 9 | "green" : "0x62", 10 | "red" : "0x93" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordYellow.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0.545", 9 | "green" : "0.796", 10 | "red" : "0.922" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/Nord/NordYellowDark.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "srgb", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x2E", 9 | "green" : "0xA2", 10 | "red" : "0xDC" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | } 15 | ], 16 | "info" : { 17 | "author" : "xcode", 18 | "version" : 1 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /financecontrol/financecontrolApp.swift: -------------------------------------------------------------------------------- 1 | // 2 | // financecontrolApp.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/06/26. 6 | // 7 | 8 | import SwiftUI 9 | 10 | @main 11 | struct financecontrolApp: App { 12 | init() { 13 | launch() 14 | } 15 | 16 | var body: some Scene { 17 | WindowGroup { 18 | ContentView() 19 | .environment(\.managedObjectContext, DataManager.shared.context) 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /financecontrolWidget/financecontrolWidgetExtension.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | com.apple.developer.default-data-protection 6 | NSFileProtectionComplete 7 | com.apple.security.application-groups 8 | 9 | group.dev.squirrelapp.squirrel 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "41422fc659ac624129ff9736794086adf81192e1d0196ff6c690a23d6a134cd3", 3 | "pins" : [ 4 | { 5 | "identity" : "applepie", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/PinkXaciD/ApplePie", 8 | "state" : { 9 | "revision" : "815b1914ad5fee3485f6ec3deb1198b4c1447c9b", 10 | "version" : "2.0.0" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/UniversalViews.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UniversalViews.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | func hideKeyboardToolbar(action: @escaping () -> Void) -> ToolbarItemGroup { 11 | return ToolbarItemGroup(placement: .keyboard) { 12 | Spacer() 13 | 14 | Button(action: action) { 15 | Label("Hide keyboard", systemImage: "keyboard.chevron.compact.down") 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /PrivacyInfo.xcprivacy: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | NSPrivacyAccessedAPITypes 6 | 7 | 8 | NSPrivacyAccessedAPIType 9 | NSPrivacyAccessedAPICategoryUserDefaults 10 | NSPrivacyAccessedAPITypeReasons 11 | 12 | 1C8F.1 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /financecontrol/Assets.xcassets/AppIcon_Image.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "iOS_AppIcon_Default_Image.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "iOS_AppIcon_Default_Image@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "iOS_AppIcon_Default_Image@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/CustomShareSheet.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomShareSheet.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/23. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomShareSheet: UIViewControllerRepresentable { 11 | @Binding var url: URL 12 | 13 | func makeUIViewController(context: Context) -> UIActivityViewController { 14 | return UIActivityViewController(activityItems: [url], applicationActivities: nil) 15 | } 16 | 17 | func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {} 18 | } 19 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/SquirrelLogoImage.imageset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "filename" : "SquirrelLogoImageWidget.png", 5 | "idiom" : "universal", 6 | "scale" : "1x" 7 | }, 8 | { 9 | "filename" : "SquirrelLogoImageWidget@2x.png", 10 | "idiom" : "universal", 11 | "scale" : "2x" 12 | }, 13 | { 14 | "filename" : "SquirrelLogoImageWidget@3x.png", 15 | "idiom" : "universal", 16 | "scale" : "3x" 17 | } 18 | ], 19 | "info" : { 20 | "author" : "xcode", 21 | "version" : 1 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /financecontrol/Data/SocialNetworkModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SocialNetworkModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2024/10/29. 6 | // 7 | 8 | import Foundation 9 | 10 | struct SocialNetworkModel: Codable, Equatable { 11 | let urlString: String 12 | let name: String 13 | let displayUsername: String 14 | 15 | enum CodingKeys: String, CodingKey { 16 | case name 17 | case urlString = "url" 18 | case displayUsername = "display_username" 19 | } 20 | 21 | func getURL() -> URL? { 22 | URL(string: self.urlString) 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Vars.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Vars.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/17. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Vars { 11 | private init() {} 12 | 13 | static let groupName: String = "group.dev.squirrelapp.squirrel" 14 | 15 | static let appIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.squirrelapp.squirrel" 16 | 17 | static let widgetIdentifier: String = appIdentifier + ".squirrelWidget" 18 | 19 | static let iCloudContainerIdentifier: String = "iCloud.dev.squirrelapp.squirrel" 20 | 21 | static let privacyBlur: CGFloat = 10 22 | } 23 | -------------------------------------------------------------------------------- /financecontrol/ViewModel/Protocols/SpendingViewModelProtocol.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SpendingViewModelProtocol.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/11/23. 6 | // 7 | 8 | import Foundation 9 | 10 | protocol SpendingViewModel: ViewModel { 11 | var cdm: CoreDataModel { get } 12 | var rvm: RatesViewModel { get } 13 | 14 | var amount: String { get } 15 | var currency: String { get } 16 | var date: Date { get } 17 | var categoryName: String { get } 18 | var categoryId: UUID { get } 19 | var place: String { get } 20 | var comment: String { get } 21 | 22 | func done() 23 | } 24 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/ViewModel/StatsRowViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsRowViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/02/19. 6 | // 7 | 8 | import Foundation 9 | 10 | final class StatsRowViewModel: ViewModel { 11 | @Published 12 | var hOffset: CGFloat = 0 13 | @Published 14 | var showLeadingButtons: UUID? = nil 15 | @Published 16 | var showTrailingButtons: UUID? = nil 17 | @Published 18 | var triggerLeadingAction: UUID? = nil 19 | @Published 20 | var triggerTrailingAction: UUID? = nil 21 | @Published 22 | var lastDragged: UUID? = nil 23 | 24 | init() {} 25 | } 26 | -------------------------------------------------------------------------------- /financecontrol/Utils/PrivacyMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PrivacyMonitor.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/12. 6 | // 7 | 8 | import Foundation 9 | 10 | final class PrivacyMonitor: ObservableObject { 11 | @Published private(set) var privacyScreenIsEnabled: Bool 12 | @Published private(set) var hideExpenseSum: Bool 13 | 14 | init(privacyScreenIsEnabled: Bool, hideExpenseSum: Bool) { 15 | self.privacyScreenIsEnabled = privacyScreenIsEnabled 16 | self.hideExpenseSum = hideExpenseSum 17 | } 18 | 19 | func changePrivacyScreenValue(_ newValue: Bool) { 20 | self.privacyScreenIsEnabled = newValue 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /financecontrol/Extensions/AnyTransition+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AnyTransition+Extensions.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/02/27. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension AnyTransition { 11 | static var maskFromTheBottomWithOpacity: AnyTransition { 12 | .modifier(active: MaskFromTheBottomModifier(isActive: true, withOpacity: true), identity: MaskFromTheBottomModifier(isActive: false, withOpacity: true)) 13 | } 14 | 15 | static var maskFromTheBottom: AnyTransition { 16 | .modifier(active: MaskFromTheBottomModifier(isActive: true, withOpacity: false), identity: MaskFromTheBottomModifier(isActive: false, withOpacity: false)) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /financecontrol/Extensions/Transition+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TransitionExtensions.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/03/14. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension AnyTransition { 11 | static var horizontalMoveForward: AnyTransition { 12 | AnyTransition.asymmetric(insertion: .move(edge: .trailing), removal: .move(edge: .leading)) 13 | } 14 | 15 | static var horizontalMoveBackward: AnyTransition { 16 | AnyTransition.asymmetric(insertion: .move(edge: .leading), removal: .move(edge: .trailing)) 17 | } 18 | 19 | static var moveFromBottom: AnyTransition { 20 | AnyTransition.move(edge: .bottom).combined(with: .opacity) 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertViewExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertViewExtension.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension View { 11 | func customAlert() -> some View { 12 | return self 13 | .overlay(alignment: .top) { 14 | CustomAlerts() 15 | } 16 | } 17 | } 18 | 19 | private struct CustomAlerts: View { 20 | @ObservedObject private var manager = CustomAlertManager.shared 21 | 22 | var body: some View { 23 | VStack(spacing: 10) { 24 | ForEach(manager.alerts.reversed()) { alert in 25 | CustomAlertView(data: alert) 26 | } 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /financecontrolWidget/Widget/AccessorySumWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryRectangularSumWidget.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/12. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessorySumWidget: Widget { 13 | let kind: String = "AccessorySumWidget" 14 | 15 | var body: some WidgetConfiguration { 16 | StaticConfiguration(kind: kind, provider: SumWidgetTimelineProvider()) { entry in 17 | AccessorySumWidgetView(entry: entry) 18 | } 19 | .configurationDisplayName("Today's expenses") 20 | .description("Your expenses for today.") 21 | .supportedFamilies([.accessoryRectangular, .accessoryCircular]) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/ViewModel/ICloudSyncViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ICloudSyncViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/01/06. 6 | // 7 | 8 | import Foundation 9 | 10 | final class ICloudSyncViewModel: ObservableObject { 11 | @Published 12 | private(set) var dataStoredInCloudKit: Bool 13 | 14 | init() { 15 | self.dataStoredInCloudKit = false 16 | 17 | Task { 18 | await updateDataStatus() 19 | } 20 | } 21 | 22 | func updateDataStatus() async { 23 | let result = await CloudKitManager.shared.hasDataInCloudKit() 24 | 25 | await MainActor.run { [weak self] in 26 | self?.dataStoredInCloudKit = result 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/ViewModel/StatsSearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsSearchViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/03/05. 6 | // 7 | 8 | import Foundation 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | final class StatsSearchViewModel: SearchViewModel { 14 | override init() { 15 | super.init() 16 | #if DEBUG 17 | let logger = Logger(subsystem: Vars.appIdentifier, category: "StatsSearchViewModel.swift") 18 | logger.debug("ViewModel initialized") 19 | #endif 20 | } 21 | 22 | deinit { 23 | #if DEBUG 24 | let logger = Logger(subsystem: Vars.appIdentifier, category: "StatsSearchViewModel.swift") 25 | logger.debug("ViewModel deinitialized") 26 | #endif 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /financecontrolWidget/Widget/AccessoryCircularAddExpenseWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryCircularAddExpenseWidget.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/13. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessoryCircularAddExpenseWidget: Widget { 13 | let kind: String = "AccessoryCircularAddExpense" 14 | 15 | var body: some WidgetConfiguration { 16 | StaticConfiguration(kind: kind, provider: AddExpenseWidgetProvider()) { entry in 17 | AccessoryCircularAddExpenseView(entry: entry) 18 | } 19 | .configurationDisplayName("Add expense") 20 | .description("Quickly add expense from lock screen.") 21 | .supportedFamilies([.accessoryCircular]) 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /financecontrolWidget/Widget/SmallSumWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // financecontrolSmallSumWidget.swift 3 | // financecontrolWidget 4 | // 5 | // Created by PinkXaciD on R 6/01/05. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | //struct SmallSumWidget: Widget { 12 | // let kind: String = "SmallSumWidget" 13 | // let currency: String = UserDefaults.standard.string(forKey: UDKeys.defaultCurrency.rawValue) ?? "JPY" 14 | // 15 | // var body: some WidgetConfiguration { 16 | // StaticConfiguration(kind: kind, provider: SumWidgetTimelineProvider()) { entry in 17 | // SmallSumWidgetView(entry: entry) 18 | // } 19 | // .configurationDisplayName("Today's expenses") 20 | // .description("Your expenses for today.") 21 | // .supportedFamilies([.systemSmall]) 22 | // } 23 | //} 24 | -------------------------------------------------------------------------------- /financecontrol/financecontrol.entitlements: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | aps-environment 6 | development 7 | com.apple.developer.icloud-container-identifiers 8 | 9 | iCloud.dev.squirrelapp.squirrel 10 | 11 | com.apple.developer.icloud-services 12 | 13 | CloudKit 14 | 15 | com.apple.developer.ubiquity-kvstore-identifier 16 | $(TeamIdentifierPrefix)$(CFBundleIdentifier) 17 | com.apple.security.application-groups 18 | 19 | group.dev.squirrelapp.squirrel 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /financecontrol/Utils/Colors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Colors.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomColor { 11 | static let nordAurora: [String:Color] = [ 12 | "nord1" : Color("NordRed"), 13 | "nord2" : Color("NordOrange"), 14 | "nord3" : Color("NordYellow"), 15 | "nord4" : Color("NordGreen"), 16 | "nord5" : Color("NordTeal"), 17 | "nord6" : Color("NordBlue"), 18 | "nord7" : Color("NordPurple"), 19 | "nord8" : Color("NordRedDark"), 20 | "nord9" : Color("NordOrangeDark"), 21 | "nord91" : Color("NordYellowDark"), 22 | "nord92" : Color("NordGreenDark"), 23 | "nord93" : Color("NordTealDark"), 24 | "nord94" : Color("NordBlueDark"), 25 | "nord95" : Color("NordPurpleDark") 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /financecontrol/View/Onboarding/OnboardingHeaderView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OnboardingHeaderView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/09/06. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct OnboardingHeaderView: View { 11 | let header: LocalizedStringKey 12 | let description: LocalizedStringKey 13 | 14 | var body: some View { 15 | HStack { 16 | VStack(alignment: .leading) { 17 | Text(header) 18 | .font(.largeTitle.bold()) 19 | .lineLimit(1) 20 | 21 | Text(description) 22 | .font(.footnote) 23 | .foregroundColor(.secondary) 24 | .lineLimit(3) 25 | } 26 | 27 | Spacer() 28 | } 29 | .foregroundColor(.primary) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/Entities/ReturnEntity/ReturnEntity+CoreDataProperties.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReturnEntity+CoreDataProperties.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/11/30. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | 12 | 13 | extension ReturnEntity { 14 | 15 | @nonobjc public class func fetchRequest() -> NSFetchRequest { 16 | return NSFetchRequest(entityName: "ReturnEntity") 17 | } 18 | 19 | @NSManaged public var id: UUID? 20 | @NSManaged public var amount: Double 21 | @NSManaged public var amountUSD: Double 22 | @NSManaged public var name: String? 23 | @NSManaged public var currency: String? 24 | @NSManaged public var date: Date? 25 | @NSManaged public var spending: SpendingEntity? 26 | 27 | } 28 | 29 | extension ReturnEntity : Identifiable { 30 | 31 | } 32 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertType.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertTypes.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum CustomAlertType: Hashable { 11 | case error, warning, success, info 12 | 13 | var color: Color { 14 | switch self { 15 | case .error: 16 | .red 17 | case .warning: 18 | .yellow 19 | case .success: 20 | .green 21 | case .info: 22 | .blue 23 | } 24 | } 25 | 26 | var haptic: UINotificationFeedbackGenerator.FeedbackType? { 27 | switch self { 28 | case .error: 29 | .error 30 | case .warning: 31 | .warning 32 | case .success: 33 | .success 34 | case .info: 35 | nil 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Squirrel 2 | 3 | ![Images](README/Screenshots/GitHubHeader_v4.png) 4 | 5 | [![Download on the App Store](README/Resources/Download_on_the_App_Store_Badge_US-UK_RGB_blk_092917.svg)](https://apps.apple.com/jp/app/squirrel-expense-tracker/id6477331498) 6 | 7 | Available for iOS 15 and later. 8 | 9 | ## Why Squirrel? 10 | - Simple and intuitive interface 11 | - No account needed 12 | - All data stored locally, on your device 13 | 14 | ## Main functions: 15 | - ### Simple and clear charts 16 | ![Charts](README/Screenshots/GitHubCharts_v4.png) 17 | 18 | - ### Track expenses in different currencies with exchange rates updated every hour. 19 | ![Currencies](README/Screenshots/GitHubCurrencies_v4.png) 20 | 21 | - ### Advanced filters and data export 22 | ![Filters and Export](README/Screenshots/GitHubFiltersAndExport_v4.png) 23 | 24 | *This code is available under a GPL v3 license. [Learn more.](LICENSE)* 25 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertManager.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class CustomAlertManager: ObservableObject { 11 | static let shared = CustomAlertManager() 12 | 13 | @Published 14 | var alerts: [CustomAlertData] 15 | 16 | private init() { 17 | self.alerts = [] 18 | } 19 | 20 | func addAlert(_ alert: CustomAlertData) { 21 | withAnimation(.bouncy) { 22 | self.alerts.append(alert) 23 | } 24 | } 25 | 26 | func removeAlert(_ id: UUID) { 27 | if !self.alerts.isEmpty, let index = self.alerts.firstIndex(where: { $0.id == id }) { 28 | let _ = withAnimation(.bouncy) { 29 | self.alerts.remove(at: index) 30 | } 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/UpperWidgetColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xFF", 9 | "green" : "0xFF", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x3C", 27 | "green" : "0x3A", 28 | "red" : "0x3A" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0x00", 9 | "green" : "0x95", 10 | "red" : "0xFF" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x0A", 27 | "green" : "0x9F", 28 | "red" : "0xFF" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /financecontrolWidget/Assets.xcassets/BottomWidgetColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "color" : { 5 | "color-space" : "display-p3", 6 | "components" : { 7 | "alpha" : "1.000", 8 | "blue" : "0xD6", 9 | "green" : "0xD1", 10 | "red" : "0xD1" 11 | } 12 | }, 13 | "idiom" : "universal" 14 | }, 15 | { 16 | "appearances" : [ 17 | { 18 | "appearance" : "luminosity", 19 | "value" : "dark" 20 | } 21 | ], 22 | "color" : { 23 | "color-space" : "display-p3", 24 | "components" : { 25 | "alpha" : "1.000", 26 | "blue" : "0x1E", 27 | "green" : "0x1C", 28 | "red" : "0x1C" 29 | } 30 | }, 31 | "idiom" : "universal" 32 | } 33 | ], 34 | "info" : { 35 | "author" : "xcode", 36 | "version" : 1 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /financecontrol/Utils/Formatters.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Formatters.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/13. 6 | // 7 | 8 | import SwiftUI 9 | 10 | func colorIdentifier(color: String) -> Color { 11 | switch color { 12 | case "Blue": 13 | return Color.blue 14 | case "Red": 15 | return Color.red 16 | case "Pink": 17 | return Color.pink 18 | case "Mint": 19 | return Color.mint 20 | case "Orange": 21 | return Color.orange 22 | case "Purple": 23 | return Color.purple 24 | case "Indigo": 25 | return Color.indigo 26 | case "Teal": 27 | return Color.teal 28 | default: 29 | return Color.accentColor 30 | } 31 | } 32 | 33 | func themeConvert(autoDarkMode: Bool, darkMode: Bool) -> ColorScheme? { 34 | if autoDarkMode { 35 | return nil 36 | } 37 | 38 | if darkMode { 39 | return .dark 40 | } else { 41 | return .light 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /financecontrol/ViewModel/SearchViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SearchViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/03/05. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | class SearchViewModel: ViewModel { 12 | @Published var search: String = "" 13 | @Published var input: String = "" 14 | var cancellables = Set() 15 | 16 | init() { 17 | updateSearch() 18 | } 19 | 20 | deinit { 21 | cancellables.cancelAll() 22 | } 23 | 24 | func getPublisher() -> Published.Publisher { 25 | return $search 26 | } 27 | 28 | func updateSearch() { 29 | self.$input 30 | .debounce(for: 0.3, scheduler: DispatchQueue.main) 31 | .sink { [weak self] value in 32 | withAnimation { 33 | self?.search = value.trimmingCharacters(in: .whitespacesAndNewlines) 34 | } 35 | } 36 | .store(in: &cancellables) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /financecontrolWidget/financecontrolWidgetBundle.swift: -------------------------------------------------------------------------------- 1 | // 2 | // financecontrolWidgetBundle.swift 3 | // financecontrolWidget 4 | // 5 | // Created by PinkXaciD on R 6/01/05. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | @main 12 | struct financecontrolWidgetBundle: WidgetBundle { 13 | 14 | @WidgetBundleBuilder 15 | var body: some Widget { 16 | getWidgets() 17 | } 18 | 19 | private func getWidgets() -> some Widget { 20 | if #available(iOS 16.0, *) { 21 | return WidgetBundleBuilder.buildBlock( 22 | // SmallSumWidget(), 23 | WeeklySpendingsAccessoryWidget(), 24 | AccessorySumWidget(), 25 | AccessoryCircularAddExpenseWidget(), 26 | WeeklySpendingsWidget() 27 | ) 28 | } else { 29 | return WidgetBundleBuilder.buildBlock( 30 | // SmallSumWidget(), 31 | WeeklySpendingsWidget() 32 | ) 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/02. 6 | // 7 | 8 | import Combine 9 | import UIKit 10 | 11 | final class CustomAlertViewModel: ObservableObject { 12 | let id: UUID 13 | 14 | let timer = Timer.publish(every: 3, on: .main, in: .common).autoconnect() 15 | var cancellables = Set() 16 | 17 | init(id: UUID, haptic: UINotificationFeedbackGenerator.FeedbackType?) { 18 | self.id = id 19 | 20 | if let haptic { 21 | HapticManager.shared.notification(haptic) 22 | } 23 | 24 | catchTimer() 25 | } 26 | 27 | func catchTimer() { 28 | self.timer 29 | .sink { [weak self] _ in 30 | if let id = self?.id { 31 | CustomAlertManager.shared.removeAlert(id) 32 | } 33 | } 34 | .store(in: &cancellables) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/ViewModel/IconRowViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconRowViewModel.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | final class IconRowViewModel: ViewModel { 11 | let icon: CustomIcon 12 | @Binding var selectedIcon: String? 13 | 14 | init(icon: CustomIcon, selection: Binding) { 15 | self.icon = icon 16 | self._selectedIcon = selection 17 | } 18 | 19 | func setIcon() { 20 | UIApplication.shared.setAlternateIconName(icon.fileName, completionHandler: completionHandler) 21 | } 22 | 23 | private func completionHandler(_ error: Error?) { 24 | if let error = error { 25 | ErrorType(error: error).publish() 26 | return 27 | } 28 | 29 | withAnimation { 30 | selectedIcon = icon.fileName 31 | } 32 | } 33 | 34 | func getIconImage() -> Image { 35 | return Image(icon.imageName, bundle: .main) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /financecontrolWidget/View/AccessorySumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryRectangularSumWidgetView.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/12. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessorySumWidgetView: View { 13 | @Environment(\.widgetFamily) private var widgetFamily 14 | 15 | let entry: SumEntry 16 | 17 | var body: some View { 18 | if widgetFamily == .accessoryCircular { 19 | AccessoryCircularSumWidgetView(entry: entry) 20 | } else { 21 | AccessoryRectangularSumWidgetView(entry: entry) 22 | } 23 | } 24 | } 25 | 26 | 27 | #if DEBUG 28 | struct AccessoryRectangularSumWidgetPreview: PreviewProvider { 29 | static var previews: some View { 30 | if #available(iOS 16.0, *) { 31 | AccessorySumWidgetView(entry: .init(date: .now, expenses: 1200, currency: "JPY")) 32 | .previewContext(WidgetPreviewContext(family: .accessoryCircular)) 33 | } 34 | } 35 | } 36 | #endif 37 | -------------------------------------------------------------------------------- /financecontrol/Extensions/Color+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorExtensions.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/09/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | extension Color { 11 | static subscript(name: String) -> Color { 12 | switch name { 13 | case "red": 14 | return .red 15 | case "orange": 16 | return .orange 17 | case "yellow": 18 | return .yellow 19 | case "green": 20 | return .green 21 | case "teal": 22 | return .teal 23 | case "blue": 24 | return .blue 25 | case "purple": 26 | return .purple 27 | case "pink": 28 | return .pink 29 | case "nord1", "nord2", "nord3", "nord4", "nord5", "nord6", "nord7", "nord8", "nord9", "nord91", "nord92", "nord93", "nord94", "nord95": 30 | return CustomColor.nordAurora[name] ?? .black 31 | case "secondary": 32 | return .secondary 33 | default: 34 | return .clear 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /financecontrol/Extensions/UDKey.swift: -------------------------------------------------------------------------------- 1 | // 2 | // UDKeys.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2024/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | enum UDKey: String { 11 | case presentOnboarding 12 | case color 13 | case defaultCurrency 14 | case defaultSelectedCurrency 15 | case savedCurrencies 16 | case minimizeLegend 17 | case rates 18 | case updateTime 19 | case updateRates 20 | case autoDarkMode 21 | case darkMode 22 | case privacyScreen 23 | case separateCurrencies 24 | case ratesFetchQueue 25 | case formatWithoutTimeZones 26 | case githubURL 27 | case appWebsiteURL 28 | case urlUpdateVersion 29 | case socialNetworksUpdateVersion 30 | case socialNetworksJSON 31 | case iCloudSync 32 | case timeZoneFormat 33 | 34 | static var urlKeys: [Self] { 35 | [.appWebsiteURL, .githubURL] 36 | } 37 | 38 | var ckID: String? { 39 | switch self { 40 | case .appWebsiteURL: 41 | "website" 42 | case .githubURL: 43 | "github" 44 | default: 45 | nil 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /financecontrolTests/CoreDataTests/CoreDataModelTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataModelTests.swift 3 | // financecontrolTests 4 | // 5 | // Created by PinkXaciD on R 5/12/28. 6 | // 7 | 8 | import XCTest 9 | @testable import Squirrel 10 | 11 | final class CoreDataModelTests: XCTestCase { 12 | 13 | var cdm: CoreDataModel? = nil 14 | 15 | override func setUp() { 16 | super.setUp() 17 | cdm = CoreDataModel() 18 | } 19 | 20 | override func tearDown() { 21 | super.tearDown() 22 | 23 | cdm = nil 24 | } 25 | 26 | func testInitSettingUpProperties() { 27 | // MARK: Currencies now setting up from onboarding 28 | // if let currencies = cdm?.savedCurrencies, let currency = currencies.first { 29 | // XCTAssertEqual(currency.tag, Locale.current.currencyCode) 30 | // } else { 31 | // XCTFail("Currency not setted up") 32 | // } 33 | 34 | // XCTAssertTrue(cdm?.savedCurrencies == []) 35 | XCTAssertTrue(cdm?.savedSpendings == []) 36 | XCTAssertTrue(cdm?.savedCategories == []) 37 | XCTAssertTrue(cdm?.shadowedCategories == []) 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /financecontrol/Error/CoreDataErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CoreDataErrors.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/02/21. 6 | // 7 | 8 | import Foundation 9 | 10 | enum CoreDataError: LocalizedError { 11 | case failedToGetEntityDescription, failedToFindCategory 12 | } 13 | 14 | extension CoreDataError { 15 | var failureReason: String? { 16 | switch self { 17 | case .failedToGetEntityDescription: 18 | return NSLocalizedString("Failed to get CoreData entity description", comment: "") 19 | case .failedToFindCategory: 20 | return NSLocalizedString("Failed to find category for this spending", comment: "") 21 | } 22 | } 23 | 24 | var errorDescription: String? { 25 | switch self { 26 | case .failedToGetEntityDescription, .failedToFindCategory: 27 | return NSLocalizedString("It seems some system files are corrupted", comment: "") 28 | } 29 | } 30 | 31 | var recoverySuggestion: String? { 32 | switch self { 33 | case .failedToGetEntityDescription, .failedToFindCategory: 34 | return NSLocalizedString("Please submit a bug report and try to restart or reinstall the app", comment: "") 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /financecontrolWidget/Widget/WeeklySpendingsWidget.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeeklySpendingsWidget.swift 3 | // SquirrelWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/07/26. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | 11 | struct WeeklySpendingsWidget: Widget { 12 | let kind = "WeeklySpendingsWidget" 13 | 14 | var body: some WidgetConfiguration { 15 | StaticConfiguration(kind: kind, provider: WeeklySpendingsWidgetProvider()) { entry in 16 | WeeklySpendingsWidgetView(entry: entry) 17 | } 18 | .configurationDisplayName("Weekly expenses") 19 | .description("Your expenses for this week.") 20 | .supportedFamilies([.systemMedium, .systemSmall]) 21 | } 22 | } 23 | 24 | @available(iOS 16.0, *) 25 | struct WeeklySpendingsAccessoryWidget: Widget { 26 | let kind = "WeeklySpendingsAccessoryWidget" 27 | 28 | var body: some WidgetConfiguration { 29 | StaticConfiguration(kind: kind, provider: WeeklySpendingsWidgetProvider()) { entry in 30 | WeeklySpendingsAccessoryWidgetView(entry: entry) 31 | } 32 | .configurationDisplayName("Weekly expenses") 33 | .description("Your expenses for this week.") 34 | .supportedFamilies([.accessoryRectangular]) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /financecontrol/Extensions/Date+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DateExtension.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/10. 6 | // 7 | 8 | import Foundation 9 | 10 | extension Date { 11 | static let firstAvailableDate: Date = Date(timeIntervalSinceReferenceDate: 599_529_600) // 2020/01/01, 0:00 GMT 12 | 13 | var previousDay: Date { 14 | guard let date = Calendar.current.date(byAdding: .day, value: -1, to: self) else { 15 | return self 16 | } 17 | return date 18 | } 19 | 20 | func getFirstDayOfMonth(_ value: Int = 0) -> Date { 21 | guard let date = Calendar.current.date(byAdding: .month, value: value, to: self) else { 22 | return self 23 | } 24 | 25 | var components: DateComponents = Calendar.current.dateComponents([.month, .year, .era], from: date) 26 | components.calendar = Calendar.current 27 | 28 | guard let newDate = components.date else { 29 | return self 30 | } 31 | 32 | return newDate 33 | } 34 | 35 | var weekAgoUnwrapped: Self { 36 | let date = Calendar.current.startOfDay(for: self) 37 | return Calendar.current.date(byAdding: .day, value: -6, to: date) ?? Date() 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /financecontrol/Utils/InputUtils.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AmountInputUtils.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/28. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct InputUtils { 11 | func checkAll(amount: String, place: String, comment: String) -> Bool { 12 | amountCheck(amount: amount) 13 | && 14 | placeCheck(place: place) 15 | && 16 | commentCheck(comment: comment) 17 | } 18 | 19 | func amountCheck(amount: String) -> Bool { 20 | let formatter = NumberFormatter.standard 21 | 22 | guard 23 | let number = formatter.number(from: amount), 24 | Double(truncating: number) < Double.greatestFiniteMagnitude, 25 | Double(truncating: number) > 0 26 | else { 27 | return false 28 | } 29 | 30 | return true 31 | } 32 | 33 | func placeCheck(place: String) -> Bool { 34 | guard 35 | place.count <= 100 36 | else { 37 | return false 38 | } 39 | 40 | return true 41 | } 42 | 43 | func commentCheck(comment: String) -> Bool { 44 | guard 45 | comment.count <= 300 46 | else { 47 | return false 48 | } 49 | 50 | return true 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /financecontrol/Extensions/URL+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // URL+Extensions.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2024/10/23. 6 | // 7 | 8 | import Foundation 9 | 10 | extension URL { 11 | static let addExpenseAction: URL! = URL(string: "squirrel://addExpense") 12 | 13 | static let github: URL = { 14 | UserDefaults.standard.url(forKey: UDKey.githubURL.rawValue) ?? URL(string: "https://github.com/PinkXaciD/Squirrel")! 15 | }() 16 | 17 | static let newGithubIssue: URL! = URL(string: "\(github.absoluteString)/issues/new") 18 | 19 | static let githubChangelog: URL! = URL(string: "\(github.absoluteString)/releases/tag/v\(Bundle.main.releaseVersionNumber ?? "")") 20 | 21 | static let appWebsite: URL = { 22 | UserDefaults.standard.url(forKey: UDKey.appWebsiteURL.rawValue) ?? URL(string: "https://squirrelapp.dev")! 23 | }() 24 | 25 | static let privacyPolicy: URL! = URL(string: "\(appWebsite.absoluteString)/privacy") 26 | 27 | static let appEmail: String = { 28 | guard let websiteURLString = URL.appWebsite.absoluteString.split(separator: "/").last else { 29 | return "contact@\(URL.appWebsite.absoluteString.replacingOccurrences(of: "https://", with: ""))" 30 | } 31 | 32 | return "contact@\(websiteURLString)" 33 | }() 34 | } 35 | -------------------------------------------------------------------------------- /financecontrol/View/Home/Views/BarChartBar.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BarChartBar.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/27. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct BarChartBar: View { 11 | let index: Int 12 | var data: (key: Date, value: Double) 13 | var isActive: Bool 14 | let maxHeight: CGFloat 15 | let cornerRadius: CGFloat 16 | 17 | var body: some View { 18 | ZStack(alignment: .bottom) { 19 | Rectangle() 20 | .frame(height: maxHeight) 21 | .foregroundStyle(.secondary) 22 | .opacity(isActive ? 0.1 : 0.07) 23 | 24 | RoundedRectangle(cornerRadius: cornerRadius) 25 | .frame(height: data.value) 26 | .foregroundStyle(.primary) 27 | .opacity(isActive ? 1 : 0.7) 28 | } 29 | .hoverEffect() 30 | } 31 | } 32 | 33 | struct BarChartBar_Previews: PreviewProvider { 34 | static var cornerRadius: CGFloat { 35 | if #available(iOS 26.0, *) { 36 | return 10 37 | } 38 | 39 | return 5 40 | } 41 | 42 | static var previews: some View { 43 | BarChartBar(index: 1, data: (key: Date.now, value: 1.0), isActive: true, maxHeight: UIScreen.main.bounds.height / 5, cornerRadius: cornerRadius) 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /financecontrol/Extensions/NumbersViewModifier.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NumberaViewModifier.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/03. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | struct NumbersViewModifier: ViewModifier { 12 | @Binding var text: String 13 | let currency: Currency 14 | 15 | func body(content: Content) -> some View { 16 | content 17 | .onReceive(Just(text.replacingOccurrences(of: "٫", with: Locale.current.decimalSeparator ?? "."))) { newValue in 18 | let decimalSeparator: String = Locale.current.decimalSeparator ?? "." 19 | var filteredText = newValue.filter { $0.isNumber || $0 == decimalSeparator.first ?? "." } 20 | 21 | while validate(filteredText.components(separatedBy: decimalSeparator)) { 22 | filteredText = String(filteredText.dropLast()) 23 | } 24 | 25 | if newValue != filteredText { 26 | self.text = filteredText 27 | } 28 | } 29 | } 30 | 31 | private func validate(_ components: [String]) -> Bool { 32 | if currency.fractionDigits == 0 { 33 | return components.count > 1 34 | } 35 | 36 | return components.count > 2 || (components.count == 2 && components[1].count > currency.fractionDigits) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertData.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/02. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomAlertData: Identifiable { 11 | let id: UUID = .init() 12 | let type: CustomAlertType 13 | let title: LocalizedStringKey 14 | let description: LocalizedStringKey? 15 | let systemImage: String 16 | 17 | init(type: CustomAlertType, title: LocalizedStringKey, description: LocalizedStringKey? = nil, systemImage: String = "questionmark.circle") { 18 | self.type = type 19 | self.title = title 20 | self.description = description 21 | self.systemImage = systemImage 22 | } 23 | 24 | static func noConnection(_ description: LocalizedStringKey? = nil) -> Self { 25 | let image: String = { 26 | if #available(iOS 17, *) { 27 | return "network.slash" 28 | } else { 29 | return "network" 30 | } 31 | }() 32 | 33 | return CustomAlertData( 34 | type: .warning, 35 | title: "No connection", 36 | description: description, 37 | systemImage: image 38 | ) 39 | } 40 | 41 | static func error(_ error: LocalizedError) -> Self { 42 | .init(type: .error, title: "Error", description: LocalizedStringKey(error.localizedDescription), systemImage: "xmark.circle") 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Info.plist 2 | Icon.png 3 | /.vscode/settings.json 4 | 5 | financecontrol/Assets.xcassets/AppIcon.appiconset/iOS_AppIcon_Default.png 6 | financecontrol/Assets.xcassets/AppIcon_Image.imageset/*.png 7 | 8 | financecontrol/Assets.xcassets/Icons/AppIcon_FirstFlight.appiconset/iOS_AppIcon_First_Flight.png 9 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_FirstFlight_Image.imageset/*.png 10 | 11 | financecontrol/Assets.xcassets/Icons/AppIcon_NeonNight.appiconset/iOS_AppIcon_Neon.png 12 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_NeonNight_Image.imageset/*.png 13 | 14 | financecontrol/Assets.xcassets/Icons/AppIcon_Winterized.appiconset/iOS_AppIcon_Winterized.png 15 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_Winterized_Image.imageset/*.png 16 | 17 | financecontrol/Assets.xcassets/Icons/AppIcon_DawnOfSquipan.appiconset/iOS_AppIcon_DawnOfSquipan.png 18 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_DawnOfSquipan_Image.imageset/*.png 19 | 20 | financecontrol/Assets.xcassets/Icons/AppIcon_NA.appiconset/iOS_AppIcon_NA.png 21 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_NA_Image.imageset/*.png 22 | 23 | financecontrol/Assets.xcassets/Icons/AppIcon_Stealth.appiconset/*.png 24 | financecontrol/Assets.xcassets/Icons/Previews/AppIcon_Stealth_Image.imageset/*.png 25 | 26 | financecontrol/Assets.xcassets/ 27 | 28 | financecontrolWidget/Assets.xcassets/SquirrelLogoImage.imageset/*.png 29 | 30 | /Squirrel.xcodeproj/xcuserdata 31 | /Squirrel.xcodeproj/project.xcworkspace/xcuserdata 32 | 33 | CommitMessage.md 34 | -------------------------------------------------------------------------------- /financecontrol/Data/Currency.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Currency.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 5/09/07. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Currency: Hashable, Comparable, Identifiable { 11 | static func < (lhs: Currency, rhs: Currency) -> Bool { 12 | return (lhs.name ?? lhs.code) < (rhs.name ?? rhs.code) 13 | } 14 | 15 | let code: String 16 | 17 | var id: String { 18 | self.code 19 | } 20 | 21 | var name: String? { 22 | Locale.current.localizedString(forCurrencyCode: code) 23 | } 24 | 25 | var fractionDigits: Int { 26 | Locale.current.currencyFractionDigits(currencyCode: self.code) 27 | } 28 | 29 | static func getAll() -> [Currency] { 30 | return Locale.customCommonISOCurrencyCodes.map { .init(code: $0) } 31 | } 32 | 33 | static var localeCurrency: Currency? { 34 | if let code = Locale.current.currencyCode { 35 | return Currency(code: code) 36 | } 37 | 38 | return nil 39 | } 40 | } 41 | 42 | extension Locale { 43 | func getCurrency() -> Squirrel.Currency? { 44 | let currencyCode = { 45 | if #available(iOS 16, *) { 46 | return self.currency?.identifier 47 | } else { 48 | return self.currencyCode 49 | } 50 | }() 51 | 52 | if let currencyCode { 53 | return .init(code: currencyCode) 54 | } 55 | 56 | return nil 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /financecontrol/Utils/LaunchActions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // LaunchActions.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/21. 6 | // 7 | 8 | import Foundation 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | func launch() { 14 | let dateFormatter = ISO8601DateFormatter() 15 | #if DEBUG 16 | let _ = CloudKitManager.shared 17 | #endif 18 | 19 | // MARK: Rates update scheduling 20 | let updateTime = UserDefaults.standard.string(forKey: UDKey.updateTime.rawValue) ?? dateFormatter.string(from: .distantPast) 21 | 22 | if !Calendar.current.isDate(dateFormatter.date(from: updateTime) ?? .distantPast, equalTo: .now, toGranularity: .hour) { 23 | #if DEBUG 24 | let logger = Logger(subsystem: Vars.appIdentifier, category: "\(#fileID)") 25 | logger.debug("Rates aren't up to date") 26 | logger.debug("Last updated at: \(updateTime)") 27 | logger.info("Updating rates...") 28 | #endif 29 | 30 | UserDefaults.standard.set(true, forKey: "updateRates") 31 | } 32 | 33 | // MARK: Currency checks 34 | if UserDefaults.standard.string(forKey: UDKey.defaultCurrency.rawValue) == nil { 35 | UserDefaults.standard.set(Locale.current.currencyCode ?? "USD", forKey: UDKey.defaultCurrency.rawValue) 36 | } 37 | 38 | if let sharedDefaults = UserDefaults(suiteName: Vars.groupName), sharedDefaults.string(forKey: UDKey.defaultCurrency.rawValue) == nil { 39 | sharedDefaults.set(Locale.current.currencyCode ?? "USD", forKey: UDKey.defaultCurrency.rawValue) 40 | WidgetsManager.shared.reloadSumWidgets() 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /financecontrolWidget/Timeline/AddExpenseWidgetProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddExpenseWidgetProvider.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/13. 6 | // 7 | 8 | import WidgetKit 9 | import SwiftUI 10 | #if DEBUG 11 | import OSLog 12 | #endif 13 | 14 | struct AddExpenseWidgetProvider: TimelineProvider { 15 | func placeholder(in context: Context) -> AddExpenseEntry { 16 | AddExpenseEntry(date: Date(), image: { Image(.squirrelLogo) }, url: .addExpenseAction) 17 | } 18 | 19 | func getSnapshot(in context: Context, completion: @escaping (AddExpenseEntry) -> Void) { 20 | let entry = AddExpenseEntry(date: Date(), image: { Image(.squirrelLogo) }, url: .addExpenseAction) 21 | completion(entry) 22 | } 23 | 24 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 25 | var entries: [AddExpenseEntry] = [] 26 | #if DEBUG 27 | let logger: Logger = .init(subsystem: Vars.widgetIdentifier, category: "Add expense timeline") 28 | #endif 29 | 30 | for _ in 0..<2 { 31 | let entryDate = Calendar.current.startOfDay(for: .init()) 32 | let entryImage = Image(.squirrelLogo) 33 | let entryURL = URL.addExpenseAction 34 | let entry = AddExpenseEntry(date: entryDate, image: { entryImage }, url: entryURL) 35 | entries.append(entry) 36 | #if DEBUG 37 | logger.debug("Generating entry... Date: \(entryDate), image name: \("squirrelLogo"), URL: \(entryURL?.absoluteString ?? "No URL")") 38 | #endif 39 | } 40 | 41 | let timeline = Timeline(entries: entries, policy: .never) 42 | completion(timeline) 43 | } 44 | } 45 | 46 | struct AddExpenseEntry: TimelineEntry { 47 | let date: Date 48 | let image: () -> Image 49 | let url: URL! 50 | } 51 | -------------------------------------------------------------------------------- /financecontrol/Utils/CloudKitKVSManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitKVSManager.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/01/07. 6 | // 7 | 8 | import Foundation 9 | import Combine 10 | 11 | final class CloudKitKVSManager: ObservableObject { 12 | @Published 13 | var iCloudSync: Bool 14 | 15 | private let store: NSUbiquitousKeyValueStore 16 | private var valueSubscription: AnyCancellable? 17 | 18 | init(store: NSUbiquitousKeyValueStore = .default) { 19 | self.iCloudSync = store.bool(forKey: UDKey.iCloudSync.rawValue) 20 | self.store = store 21 | 22 | NotificationCenter.default.addObserver( 23 | self, 24 | selector: #selector(update(_:)), 25 | name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 26 | object: store 27 | ) 28 | 29 | self.toggleKVS() 30 | 31 | store.synchronize() 32 | } 33 | 34 | deinit { 35 | NotificationCenter.default.removeObserver( 36 | self, 37 | name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 38 | object: self.store 39 | ) 40 | } 41 | 42 | @objc 43 | func update(_ notification: Notification) { 44 | guard let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? Array else { 45 | return 46 | } 47 | 48 | if changedKeys.contains(UDKey.iCloudSync.rawValue) { 49 | DispatchQueue.main.async { 50 | self.iCloudSync = self.store.bool(forKey: UDKey.iCloudSync.rawValue) 51 | } 52 | } 53 | } 54 | 55 | private func toggleKVS() { 56 | self.valueSubscription = self.$iCloudSync 57 | .sink { newValue in 58 | self.store.set(newValue, forKey: UDKey.iCloudSync.rawValue) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /financecontrol/Utils/HapticManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // HapticManager.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/25. 6 | // 7 | 8 | import UIKit 9 | #if targetEnvironment(simulator) 10 | import OSLog 11 | #endif 12 | 13 | final class HapticManager { 14 | static let shared = HapticManager() 15 | 16 | func notification(_ type: UINotificationFeedbackGenerator.FeedbackType) { 17 | let generator = UINotificationFeedbackGenerator() 18 | generator.notificationOccurred(type) 19 | #if targetEnvironment(simulator) 20 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 21 | logger.debug("\(type.debugDescription) haptic occured") 22 | #endif 23 | } 24 | 25 | func impact(_ style: UIImpactFeedbackGenerator.FeedbackStyle) { 26 | let generator = UIImpactFeedbackGenerator(style: style) 27 | generator.impactOccurred() 28 | #if targetEnvironment(simulator) 29 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 30 | logger.debug("\(style.debugDescription) haptic occured") 31 | #endif 32 | } 33 | } 34 | 35 | #if targetEnvironment(simulator) 36 | extension UINotificationFeedbackGenerator.FeedbackType { 37 | var debugDescription: String { 38 | switch self { 39 | case .success: 40 | "Success" 41 | case .warning: 42 | "Warning" 43 | case .error: 44 | "Error" 45 | @unknown default: 46 | "Unknown" 47 | } 48 | } 49 | } 50 | 51 | extension UIImpactFeedbackGenerator.FeedbackStyle { 52 | var debugDescription: String { 53 | switch self { 54 | case .light: 55 | "Light" 56 | case .medium: 57 | "Medium" 58 | case .heavy: 59 | "Heavy" 60 | case .soft: 61 | "Soft" 62 | case .rigid: 63 | "Rigid" 64 | @unknown default: 65 | "Unknown" 66 | } 67 | } 68 | } 69 | #endif 70 | -------------------------------------------------------------------------------- /financecontrolTests/FallbackRatesTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesViewModelTests.swift 3 | // financecontrolTests 4 | // 5 | // Created by PinkXaciD on R 5/12/28. 6 | // 7 | 8 | import XCTest 9 | @testable import Squirrel 10 | 11 | final class FallbackRatesTests: XCTestCase { 12 | 13 | var isoDateFormatter: ISO8601DateFormatter { 14 | let formatter = ISO8601DateFormatter() 15 | formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] 16 | return formatter 17 | } 18 | 19 | override func setUpWithError() throws { 20 | // Put setup code here. This method is called before the invocation of each test method in the class. 21 | } 22 | 23 | override func tearDownWithError() throws { 24 | // Put teardown code here. This method is called after the invocation of each test method in the class. 25 | } 26 | 27 | func testFallbackRatesTimestampParsing() { 28 | let fallbackDate = isoDateFormatter.date(from: Rates.fallback.timestamp) 29 | 30 | XCTAssertNotNil(fallbackDate) 31 | } 32 | 33 | func testFallbackRatesIsUpToDate() { 34 | let fallbackDate = isoDateFormatter.date(from: Rates.fallback.timestamp)! 35 | let minimalAcceptDate = Calendar.current.date(byAdding: .day, value: -7, to: .now)! 36 | 37 | XCTAssertLessThan(minimalAcceptDate, fallbackDate) 38 | } 39 | 40 | func testFallbackRatesHasAllValues() { 41 | let fallbackCurrencies = Rates.fallback.rates.keys 42 | var requiredCurrencies = Locale.customCommonISOCurrencyCodes.filter { $0 != "SLE" && $0 != "VEF" } 43 | 44 | for code in fallbackCurrencies { 45 | let index = requiredCurrencies.firstIndex(of: code) 46 | 47 | XCTAssertNotNil(index, code) 48 | 49 | if let index { 50 | requiredCurrencies.remove(at: index) 51 | } 52 | } 53 | 54 | XCTAssertTrue(requiredCurrencies.isEmpty, requiredCurrencies.description) 55 | } 56 | 57 | } 58 | -------------------------------------------------------------------------------- /financecontrol/Utils/CustomIcon.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomIcon.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | enum CustomIcon: CaseIterable { 11 | case sqwoorl, firstFlight, neonNight, winterized, dawnOfSquipan, ghost, NA 12 | } 13 | 14 | extension CustomIcon { 15 | var displayName: LocalizedStringKey { 16 | switch self { 17 | case .sqwoorl: 18 | "Sqwoorl" 19 | case .firstFlight: 20 | "First Flight" 21 | case .neonNight: 22 | "Neon Night" 23 | case .winterized: 24 | "Winterized" 25 | case .dawnOfSquipan: 26 | "Dawn of Squipan" 27 | case .NA: 28 | "N:A" 29 | case .ghost: 30 | "Ghost" 31 | } 32 | } 33 | 34 | var description: LocalizedStringKey { 35 | switch self { 36 | case .sqwoorl: 37 | "Yes, that's his real name" 38 | case .firstFlight: 39 | "To the store!" 40 | case .neonNight: 41 | "It's probably all LED's nowadays" 42 | case .winterized: 43 | "Even a squirrel needs a hat in winter" 44 | case .dawnOfSquipan: 45 | "App of the rising sun" 46 | case .NA: 47 | "Everything that lives is designed to end. We are perpetually trapped in a never-ending spiral..." 48 | case .ghost: 49 | "Plays for the audience" 50 | } 51 | } 52 | 53 | var fileName: String? { 54 | switch self { 55 | case .sqwoorl: 56 | nil 57 | case .firstFlight: 58 | "AppIcon_FirstFlight" 59 | case .neonNight: 60 | "AppIcon_NeonNight" 61 | case .winterized: 62 | "AppIcon_Winterized" 63 | case .dawnOfSquipan: 64 | "AppIcon_DawnOfSquipan" 65 | case .NA: 66 | "AppIcon_NA" 67 | case .ghost: 68 | "AppIcon_Ghost" 69 | } 70 | } 71 | 72 | var imageName: String { 73 | "\(fileName ?? "AppIcon")_Image" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/PieChartCompleteView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PieChartCompleteView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/02/03. 6 | // 7 | 8 | import SwiftUI 9 | import ApplePie 10 | 11 | struct PieChartCompleteView: View { 12 | @Environment(\.layoutDirection) private var layoutDirection 13 | @EnvironmentObject private var vm: PieChartViewModel 14 | let data: ChartData 15 | let size: CGFloat 16 | @State private var update: Bool = false 17 | 18 | var body: some View { 19 | ZStack { 20 | if let selectedCategory = vm.selectedCategory { 21 | APChart( 22 | data.categoriesDict[selectedCategory.id]?.places ?? [], 23 | separators: 0.3, 24 | innerRadius: 0.73, 25 | animation: .default 26 | ) { element in 27 | APChartSector(element.sum, color: Color[element.color], id: element.id) 28 | } 29 | } else { 30 | APChart( 31 | categories(), 32 | separators: 0.3, 33 | innerRadius: 0.73, 34 | animation: .default 35 | ) { element in 36 | APChartSector(element.sum, color: Color[element.color], id: element.id) 37 | } 38 | } 39 | 40 | CenterChartView( 41 | selectedMonth: data.date, 42 | width: size, 43 | operationsInMonth: vm.selectedCategory == nil ? data.sum : data.categoriesDict[vm.selectedCategory?.id ?? .init()]?.sum ?? 0 44 | ) 45 | } 46 | } 47 | 48 | private func categories() -> [ChartCategory] { 49 | if vm.showOther { 50 | return data.categories + data.otherCategories 51 | } 52 | 53 | if let otherCategory = data.otherCategory { 54 | var result = data.categories 55 | result.append(otherCategory) 56 | return result 57 | } 58 | 59 | return data.categories 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /financecontrolWidget/View/AccessoryCircularAddExpenseView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryCircularAddExpenseView.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/13. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessoryCircularAddExpenseView: View { 13 | let entry: AddExpenseEntry 14 | 15 | var body: some View { 16 | if #available(iOS 17.0, *) { 17 | getNewWidget() 18 | } else { 19 | getOldWidget() 20 | } 21 | } 22 | 23 | @available(iOS 17.0, *) 24 | private func getNewWidget() -> some View { 25 | ZStack(alignment: .center) { 26 | Circle() 27 | .fill(Material.regular) 28 | 29 | entry.image() 30 | .resizable() 31 | .aspectRatio(contentMode: .fit) 32 | .padding(10) 33 | } 34 | .privacySensitive(false) 35 | .containerBackground(.clear, for: .widget) 36 | .widgetURL(entry.url) 37 | } 38 | 39 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()") 40 | private func getOldWidget() -> some View { 41 | ZStack { 42 | Circle() 43 | .fill(Material.regular) 44 | 45 | entry.image() 46 | .resizable() 47 | .aspectRatio(contentMode: .fit) 48 | .padding(10) 49 | } 50 | .privacySensitive(false) 51 | .widgetURL(entry.url) 52 | } 53 | } 54 | 55 | #if DEBUG 56 | struct AccessoryCircularAddExpenseViewPreviews: PreviewProvider { 57 | static var previews: some View { 58 | if #available(iOS 16.0, *){ 59 | AccessoryCircularAddExpenseView( 60 | entry: .init( 61 | date: .init(), 62 | image: { Image(.squirrelLogo) }, 63 | url: URL(string:"financecontrol://addExpense") 64 | ) 65 | ) 66 | .previewContext(WidgetPreviewContext(family: .accessoryCircular)) 67 | } 68 | } 69 | } 70 | #endif 71 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Category/ShadowedCategoriesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShadowedCategoriesView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/30. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ShadowedCategoriesView: View { 11 | // @EnvironmentObject var cdm: CoreDataModel 12 | 13 | let categories: FetchedResults 14 | 15 | var body: some View { 16 | Group { 17 | List { 18 | if !categories.isEmpty { 19 | Section { 20 | ForEach(categories) { category in 21 | ShadowedCategoriesRow(category: category, safeCategory: category.safeObject()) 22 | } 23 | } footer: { 24 | Text("Swipe from left to restore, swipe from right to delete") 25 | } 26 | } 27 | } 28 | .overlay { 29 | if categories.isEmpty { 30 | ZStack { 31 | Color(uiColor: .systemGroupedBackground) 32 | 33 | CustomContentUnavailableView( 34 | "No Archived Categories", 35 | imageName: "archivebox.fill", 36 | description: "You can archive categories in settings, they will be hidden from selection when you add new expenses, but all old expenses will be saved." 37 | ) 38 | } 39 | .ignoresSafeArea() 40 | } 41 | } 42 | .navigationTitle("Archived Categories") 43 | } 44 | .animation(.default, value: categories.count) 45 | } 46 | } 47 | 48 | struct ShadowedCategoriesView_Previews: PreviewProvider { 49 | @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)], predicate: NSPredicate(format: "isShadowed == true"), animation: .default) 50 | static private var categories: FetchedResults 51 | 52 | static var previews: some View { 53 | ShadowedCategoriesView(categories: categories) 54 | .environmentObject(CoreDataModel()) 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /financecontrolWidget/View/AccessoryRectangularSumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryRectangularSumWidgetView.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessoryRectangularSumWidgetView: View { 13 | let entry: SumEntry 14 | 15 | var body: some View { 16 | if #available(iOS 17.0, *) { 17 | getNewWidget() 18 | } else { 19 | getOldWidget() 20 | } 21 | } 22 | 23 | @available(iOS 17.0, *) 24 | private func getNewWidget() -> some View { 25 | HStack { 26 | VStack(alignment: .leading) { 27 | Text("Today's expenses") 28 | .privacySensitive(false) 29 | 30 | Text(entry.expenses.formatted(.currency(code: entry.currency))) 31 | .font(.system(size: 30, design: .rounded).bold()) 32 | .minimumScaleFactor(0.5) 33 | .privacySensitive() 34 | } 35 | 36 | Spacer() 37 | } 38 | .containerBackground(.clear, for: .widget) 39 | } 40 | 41 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()") 42 | private func getOldWidget() -> some View { 43 | HStack { 44 | VStack(alignment: .leading) { 45 | Text("Today's expenses") 46 | 47 | Text(entry.expenses.formatted(.currency(code: entry.currency))) 48 | .font(.system(size: 30, design: .rounded).bold()) 49 | .minimumScaleFactor(0.5) 50 | .privacySensitive() 51 | } 52 | 53 | Spacer() 54 | } 55 | } 56 | } 57 | 58 | #if DEBUG 59 | @available(iOS 16.0, *) 60 | struct AccessoryRectangularSumWidgetViewPreviews: PreviewProvider { 61 | static var previews: some View { 62 | AccessoryRectangularSumWidgetView( 63 | entry: .init( 64 | date: .now, 65 | expenses: 1200, 66 | currency: "JPY" 67 | ) 68 | ) 69 | .previewContext(WidgetPreviewContext(family: .accessoryRectangular)) 70 | .previewDisplayName("Accessory Rectangular Sum Widget") 71 | } 72 | } 73 | #endif 74 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/PieChartCenterView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CenterChartView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/11/21. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CenterChartView: View { 11 | @EnvironmentObject 12 | private var cdm: CoreDataModel 13 | @EnvironmentObject 14 | private var rvm: RatesViewModel 15 | @EnvironmentObject 16 | private var fvm: FiltersViewModel 17 | @AppStorage(UDKey.defaultCurrency.rawValue) 18 | var defaultCurrency: String = Locale.current.currencyCode ?? "USD" 19 | var selectedMonth: Date 20 | 21 | let width: CGFloat 22 | let operationsInMonth: Double 23 | 24 | var body: some View { 25 | VStack(alignment: .center) { 26 | 27 | dateText() 28 | .padding(.top, 5) 29 | .scaledToFit() 30 | 31 | Text(operationsSum(operationsInMonth: operationsInMonth)) 32 | .lineLimit(1) 33 | .font(.system(size: 30, weight: .semibold, design: .rounded)) 34 | .padding(.horizontal, 6) 35 | .minimumScaleFactor(0.5) 36 | .scaledToFit() 37 | 38 | Text(defaultCurrency) 39 | .foregroundColor(Color.secondary) 40 | } 41 | .frame(maxWidth: width/1.4) 42 | .scaledToFit() 43 | .minimumScaleFactor(0.01) 44 | } 45 | } 46 | 47 | extension CenterChartView { 48 | internal init(selectedMonth: Date, width: CGFloat, operationsInMonth: Double) { 49 | self.selectedMonth = selectedMonth 50 | self.width = width 51 | self.operationsInMonth = operationsInMonth 52 | } 53 | 54 | @ViewBuilder 55 | private func dateText() -> some View { 56 | if fvm.applyFilters { 57 | Text("Filters applied") 58 | } else { 59 | if Calendar.current.isDate(selectedMonth, equalTo: Date(), toGranularity: .year) { 60 | Text(selectedMonth, format: .dateTime.month(.wide)) 61 | } else { 62 | Text(selectedMonth, format: .dateTime.month().year()) 63 | } 64 | } 65 | } 66 | 67 | private func operationsSum(operationsInMonth: Double) -> String { 68 | return Locale.current.currencyNarrowFormat(operationsInMonth, currency: UserDefaults.defaultCurrency()) ?? "Error" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Currency/NewCurrencyRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NewCurrencyRow.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct NewCurrencyRow: View { 11 | @EnvironmentObject private var cdm: CoreDataModel 12 | @EnvironmentObject private var rvm: RatesViewModel 13 | @Environment(\.dismiss) private var dismiss 14 | @AppStorage(UDKey.defaultCurrency.rawValue) private var defaultCurrency: String = Locale.current.currencyCode ?? "USD" 15 | 16 | let name: String 17 | let code: String 18 | 19 | var body: some View { 20 | Button(action: addCurrency) { 21 | buttonLabel 22 | } 23 | .normalizePadding() 24 | } 25 | 26 | private var buttonLabel: some View { 27 | VStack(alignment: .leading) { 28 | Text(name.capitalized) 29 | .foregroundStyle(.primary) 30 | 31 | if rvm.rates[code] != nil, rvm.rates[defaultCurrency] != nil { 32 | getRateRepresentation() 33 | .font(.footnote) 34 | .foregroundStyle(.secondary) 35 | } else { 36 | Text("No up to date exchange rate found for \(code)") 37 | .font(.footnote) 38 | .foregroundStyle(.secondary) 39 | } 40 | } 41 | .padding(.vertical, 1) /// Strange behavior without padding 42 | .foregroundStyle(Color.primary, Color.secondary) 43 | } 44 | 45 | private func addCurrency() { 46 | UserDefaults.standard.addCurrency(code) 47 | dismiss() 48 | } 49 | 50 | private func getRateRepresentation() -> Text { 51 | guard 52 | let rate = rvm.rates[code], 53 | let defaultRate = rvm.rates[defaultCurrency] 54 | else { return Text("Error") } 55 | 56 | func format1(_ code: String) -> String { 57 | return 1.formatted(.currency(code: code).precision(.fractionLength(0)).presentation(.isoCode)) 58 | } 59 | 60 | if ((1 / rate) * defaultRate) > 1 { 61 | return Text("\(format1(code)) = \(((1 / rate) * defaultRate).formatted(.currency(code: defaultCurrency).presentation(.isoCode)))") 62 | } else { 63 | return Text("\(format1(defaultCurrency)) = \(((1 / defaultRate) * rate).formatted(.currency(code: code).presentation(.isoCode)))") 64 | } 65 | } 66 | } 67 | 68 | //#Preview { 69 | // NewCurrencyRow() 70 | //} 71 | -------------------------------------------------------------------------------- /financecontrolWidget/Timeline/SumWidgetTimelineProvider.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SumWidgetTimelineProvider.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/12. 6 | // 7 | 8 | import WidgetKit 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | struct SumWidgetTimelineProvider: TimelineProvider { 14 | func placeholder(in context: Context) -> SumEntry { 15 | SumEntry(date: Date(), expenses: 100, currency: "USD") 16 | } 17 | 18 | func getSnapshot(in context: Context, completion: @escaping (SumEntry) -> Void) { 19 | let sharedDefaults: UserDefaults? = UserDefaults(suiteName: Vars.groupName) 20 | let localeCurrency: String? = Locale.current.currencySymbol 21 | 22 | let entry = SumEntry( 23 | date: Date(), 24 | expenses: sharedDefaults?.double(forKey: "amount") ?? 0, 25 | currency: sharedDefaults?.string(forKey: "defaultCurrency") ?? localeCurrency ?? "USD" 26 | ) 27 | completion(entry) 28 | } 29 | 30 | func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { 31 | var entries: [SumEntry] = [] 32 | let defaults: UserDefaults? = .init(suiteName: Vars.groupName) 33 | #if DEBUG 34 | let logger: Logger = .init(subsystem: Vars.widgetIdentifier, category: "Sum widget timelines") 35 | #endif 36 | 37 | let currentDate = Calendar.current.startOfDay(for: .now) 38 | for dayOffset in 0..<2 { 39 | let amountDate = defaults?.object(forKey: "date") as? Date 40 | 41 | let entryDate = Calendar.current.date(byAdding: .day, value: dayOffset, to: currentDate)! 42 | let entryExpenses = Calendar.current.isDate(amountDate ?? .distantPast, inSameDayAs: entryDate) ? (defaults?.double(forKey: "amount") ?? 0) : 0 43 | let entryCurrency: String = defaults?.string(forKey: "defaultCurrency") ?? Locale.current.currencyCode ?? "USD" 44 | 45 | #if DEBUG 46 | logger.debug("Generating entry... Date: \(entryDate), expenses: \(entryExpenses), currency: \(entryCurrency)") 47 | #endif 48 | 49 | let entry = SumEntry(date: entryDate, expenses: entryExpenses, currency: entryCurrency) 50 | entries.append(entry) 51 | } 52 | 53 | let timeline = Timeline(entries: entries, policy: .atEnd) 54 | completion(timeline) 55 | } 56 | } 57 | 58 | struct SumEntry: TimelineEntry { 59 | let date: Date 60 | let expenses: Double 61 | let currency: String 62 | } 63 | -------------------------------------------------------------------------------- /financecontrol/Error/RatesErrors.swift: -------------------------------------------------------------------------------- 1 | // 2 | // RatesErrors.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/09/26. 6 | // 7 | 8 | import Foundation 9 | 10 | enum InfoPlistError: LocalizedError { 11 | case noInfoFound 12 | case noURLFound 13 | case noAPIKeyFound 14 | case failedToReadURLComponents 15 | } 16 | 17 | extension InfoPlistError { 18 | public var failureReason: String? { 19 | switch self { 20 | case .noInfoFound: 21 | return NSLocalizedString("No Info file was found", comment: "") 22 | case .noURLFound: 23 | return NSLocalizedString("No URL was found", comment: "") 24 | case .noAPIKeyFound: 25 | return NSLocalizedString("No API key was found", comment: "") 26 | case .failedToReadURLComponents: 27 | return NSLocalizedString("Failed to create URL from given components", comment: "") 28 | } 29 | 30 | } 31 | 32 | public var errorDescription: String? { 33 | switch self { 34 | case .noInfoFound, .failedToReadURLComponents: 35 | return NSLocalizedString("It seems some system files are missing or corrupted", comment: "") 36 | case .noURLFound, .noAPIKeyFound: 37 | return NSLocalizedString("It seems some system files are corrupted", comment: "") 38 | } 39 | } 40 | 41 | public var recoverySuggestion: String? { 42 | switch self { 43 | case .noInfoFound, .failedToReadURLComponents: 44 | return NSLocalizedString("Please submit a bug report and try to reinstall the app", comment: "") 45 | case .noURLFound, .noAPIKeyFound: 46 | return NSLocalizedString("Please submit a bug report and try to restart the app", comment: "") 47 | } 48 | } 49 | } 50 | 51 | enum RatesFetchError: LocalizedError { 52 | case emptyDatabase 53 | } 54 | 55 | extension RatesFetchError { 56 | var errorDescription: String? { 57 | switch self { 58 | case .emptyDatabase: 59 | return NSLocalizedString("It seems some system files are missing or corrupted", comment: "") 60 | } 61 | } 62 | 63 | var recoverySuggestion: String? { 64 | switch self { 65 | case .emptyDatabase: 66 | return NSLocalizedString("Please submit a bug report and try to reinstall the app", comment: "") 67 | } 68 | } 69 | 70 | var failureReason: String? { 71 | switch self { 72 | case .emptyDatabase: 73 | return NSLocalizedString("Empty database", comment: "") 74 | } 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Currency/CurrencyRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CurrencySelectorRow.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/15. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CurrencyRow: View { 11 | @EnvironmentObject private var cdm: CoreDataModel 12 | @EnvironmentObject private var rvm: RatesViewModel 13 | @Binding var defaultCurrency: String 14 | 15 | let currency: Currency 16 | let selectedText: LocalizedStringKey 17 | 18 | var body: some View { 19 | ZStack { 20 | HStack { 21 | VStack(alignment: .leading) { 22 | Text(currency.name ?? "Error") 23 | 24 | getRateRepresentation() 25 | .font(.caption) 26 | .foregroundColor(.secondary) 27 | } 28 | 29 | Spacer() 30 | 31 | if defaultCurrency == currency.code { 32 | Image(systemName: "checkmark") 33 | .font(.body.bold()) 34 | .foregroundColor(.accentColor) 35 | } 36 | } 37 | } 38 | .background { 39 | Rectangle() 40 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground).opacity(0.001)) 41 | .frame(maxWidth: .infinity, maxHeight: .infinity) 42 | } 43 | .padding(.vertical, 1) /// Strange behavior without padding 44 | .normalizePadding() 45 | } 46 | 47 | private func getRateRepresentation() -> Text { 48 | guard currency.code != defaultCurrency else { 49 | return Text(selectedText) 50 | } 51 | 52 | guard 53 | let rate = rvm.rates[currency.code], 54 | let defaultRate = rvm.rates[defaultCurrency] 55 | else { 56 | return Text("No up to date exchange rate found") 57 | } 58 | 59 | func format1(_ code: String) -> String { 60 | return 1.formatted(.currency(code: code).precision(.fractionLength(0)).presentation(.isoCode)) 61 | } 62 | 63 | if ((1 / rate) * defaultRate) > 1 { 64 | return Text("\(format1(currency.code)) = \(((1 / rate) * defaultRate).formatted(.currency(code: defaultCurrency).presentation(.isoCode)))") 65 | } else { 66 | return Text("\(format1(defaultCurrency)) = \(((1 / defaultRate) * rate).formatted(.currency(code: currency.code).presentation(.isoCode)))") 67 | } 68 | } 69 | } 70 | 71 | //#Preview { 72 | // CurrencyRow() 73 | //} 74 | -------------------------------------------------------------------------------- /financecontrol/View/Home/ViewModels/BarChartViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // BarChartViewModel.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/01/21. 6 | // 7 | 8 | import CoreData 9 | 10 | final class BarChartViewModel: ViewModel { 11 | let context: NSManagedObjectContext 12 | 13 | @Published 14 | private(set) var data: NewBarChartData = NewBarChartData() 15 | @Published 16 | private(set) var lastFetchDate: Date? = nil 17 | 18 | init(context: NSManagedObjectContext) { 19 | self.context = context 20 | 21 | NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .UpdatePieChart, object: nil) 22 | 23 | updateData() 24 | } 25 | 26 | deinit { 27 | NotificationCenter.default.removeObserver(self, name: .UpdatePieChart, object: nil) 28 | } 29 | 30 | @objc 31 | private func updateData() { 32 | context.perform { [weak self] in 33 | guard let self else { return } 34 | 35 | let weekAgo = Calendar.autoupdatingCurrent.startOfDay(for: Date()).addingTimeInterval(.day * -6) 36 | let defaultCurrency = UserDefaults.standard.string(forKey: UDKey.defaultCurrency.rawValue) ?? "USD" 37 | let defaultRate = UserDefaults.standard.getRates()?[defaultCurrency] ?? 1 38 | 39 | let request = SpendingEntity.fetchRequest() 40 | request.sortDescriptors = [NSSortDescriptor(keyPath: \SpendingEntity.date, ascending: false)] 41 | request.predicate = NSPredicate(format: "date > %@", weekAgo as CVarArg) 42 | 43 | var data = NewBarChartData().bars 44 | var sum: Double = 0 45 | 46 | do { 47 | let spendings = try context.fetch(request) 48 | 49 | for spending in spendings { 50 | let startOfDay = Calendar.autoupdatingCurrent.startOfDay(for: spending.wrappedDate) 51 | 52 | let spendingSum = defaultCurrency == spending.wrappedCurrency ? spending.amountWithReturns : (spending.amountUSDWithReturns * defaultRate) 53 | 54 | data.updateValue((data[startOfDay] ?? 0) + spendingSum, forKey: startOfDay) 55 | 56 | sum += spendingSum 57 | } 58 | 59 | self.data = NewBarChartData(sum: sum, bars: data) 60 | self.lastFetchDate = Date() 61 | } catch { 62 | ErrorType(error: error).publish() 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /financecontrolWidget/View/AccessoryCircularSumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AccessoryCircularSumWidgetView.swift 3 | // financecontrolWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/01/25. 6 | // 7 | 8 | import SwiftUI 9 | import WidgetKit 10 | 11 | @available(iOS 16.0, *) 12 | struct AccessoryCircularSumWidgetView: View { 13 | let entry: SumEntry 14 | 15 | var body: some View { 16 | if #available(iOS 17.0, *) { 17 | getNewWidget() 18 | } else { 19 | getOldWidget() 20 | } 21 | } 22 | 23 | @available(iOS 17.0, *) 24 | private func getNewWidget() -> some View { 25 | ZStack(alignment: .center) { 26 | Circle() 27 | .fill(Material.regular) 28 | 29 | VStack(spacing: 0) { 30 | Text(entry.currency) 31 | .font(.footnote) 32 | 33 | Text(entry.expenses.formatted(.number.notation(.compactName))) 34 | .font(.system(.title, design: .rounded).bold()) 35 | .minimumScaleFactor(0.5) 36 | .scaledToFit() 37 | .padding(.horizontal, 10) 38 | } 39 | .privacySensitive() 40 | } 41 | .containerBackground(.clear, for: .widget) 42 | } 43 | 44 | @available(iOS, introduced: 16, deprecated: 17, message: "On newer platforms use getNewWidget()") 45 | private func getOldWidget() -> some View { 46 | ZStack(alignment: .center) { 47 | Circle() 48 | .fill(Material.regular) 49 | 50 | VStack(spacing: 0.5) { 51 | Text(entry.currency) 52 | .font(.footnote) 53 | 54 | Text(entry.expenses.formatted(.number.notation(.compactName))) 55 | .font(.system(.title, design: .rounded).bold()) 56 | .minimumScaleFactor(0.5) 57 | .scaledToFit() 58 | .padding(.horizontal, 6) 59 | } 60 | .privacySensitive() 61 | } 62 | } 63 | } 64 | 65 | #if DEBUG 66 | @available(iOS 16.0, *) 67 | struct AccessoryCircularSumWidgetViewPreviews: PreviewProvider { 68 | static var previews: some View { 69 | AccessoryCircularSumWidgetView( 70 | entry: .init( 71 | date: .init(), 72 | expenses: 122, 73 | currency: "JPY" 74 | ) 75 | ) 76 | .previewContext(WidgetPreviewContext(family: .accessoryCircular)) 77 | .previewDisplayName("Accessory Circular Sum Widget") 78 | } 79 | } 80 | #endif 81 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/EditReturnViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // EditReturnViewModel.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/12/05. 6 | // 7 | 8 | import SwiftUI 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | final class EditReturnViewModel: ViewModel { 14 | @Published var amount: String 15 | @Published var name: String 16 | @Published var date: Date 17 | @Published var currency: String 18 | var oldAmount: Double 19 | var spending: SpendingEntity? 20 | var returnEntity: ReturnEntity 21 | private var cdm: CoreDataModel 22 | private var rvm: RatesViewModel 23 | 24 | init(returnEntity: ReturnEntity, cdm: CoreDataModel, rvm: RatesViewModel) { 25 | let formatter = NumberFormatter.currency 26 | 27 | self.amount = formatter.string(from: returnEntity.amount as NSNumber) ?? "\(returnEntity.amount)".replacingOccurrences(of: ".", with: Locale.current.decimalSeparator ?? ".") 28 | self.name = returnEntity.name ?? "" 29 | self.date = returnEntity.date ?? .now 30 | self.currency = returnEntity.currency ?? "USD" 31 | self.oldAmount = returnEntity.amount 32 | self.spending = returnEntity.spending 33 | self.returnEntity = returnEntity 34 | self.cdm = cdm 35 | self.rvm = rvm 36 | } 37 | 38 | var doubleAmount: Double { 39 | if currency == spending?.wrappedCurrency { 40 | return Double(truncating: NumberFormatter.standard.number(from: amount) ?? 0) 41 | } else { 42 | let doubleAmount = Double(truncating: NumberFormatter.standard.number(from: amount) ?? 0) 43 | 44 | return round(doubleAmount / (rvm.rates[currency] ?? 1) * (rvm.rates[spending?.wrappedCurrency ?? "USD"] ?? 1) * 100) / 100 45 | } 46 | } 47 | 48 | func editFromSpending(spending: SpendingEntity) { 49 | let amountUSD: Double = doubleAmount / (spending.amount / spending.amountUSD) 50 | 51 | cdm.editRerturnFromSpending( 52 | spending: spending, 53 | oldReturn: returnEntity, 54 | amount: doubleAmount, 55 | amountUSD: amountUSD, 56 | currency: spending.wrappedCurrency, 57 | date: date, 58 | name: name 59 | ) 60 | } 61 | 62 | func validate() -> Bool { 63 | guard 64 | !amount.isEmpty, 65 | doubleAmount != 0, 66 | let spending = self.spending, 67 | doubleAmount <= (spending.amountWithReturns + oldAmount) 68 | else { 69 | return true 70 | } 71 | 72 | return false 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/IconRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IconRow.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/08. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct IconRow: View { 11 | @StateObject private var vm: IconRowViewModel 12 | @Binding var selectedIcon: String? 13 | private let iconSize: CGFloat = 60 14 | private let cornerRadius: CGFloat = 13.5 15 | 16 | var body: some View { 17 | Button(action: vm.setIcon, label: label) 18 | .contextMenu { 19 | Button { 20 | vm.setIcon() 21 | } label: { 22 | Text("Set icon") 23 | } 24 | 25 | } 26 | } 27 | 28 | private func label() -> some View { 29 | HStack { 30 | icon 31 | 32 | text 33 | 34 | Spacer() 35 | 36 | checkmark 37 | } 38 | .normalizePadding() 39 | } 40 | 41 | private var icon: some View { 42 | vm.getIconImage() 43 | .resizable() 44 | .frame(width: iconSize, height: iconSize) 45 | .cornerRadius(cornerRadius) 46 | .setDarkModeForIcon() 47 | .overlay(iconOverlay) 48 | .hoverEffect(.lift) 49 | } 50 | 51 | private var iconOverlay: some View { 52 | RoundedRectangle(cornerRadius: cornerRadius) 53 | .stroke(lineWidth: 1) 54 | .foregroundColor(.primary) 55 | .opacity(0.3) 56 | } 57 | 58 | private var text: some View { 59 | VStack(alignment: .leading) { 60 | Text(vm.icon.displayName) 61 | .foregroundColor(.primary) 62 | 63 | if vm.icon.description != "" { 64 | Text(vm.icon.description) 65 | .font(.caption) 66 | .foregroundColor(.secondary) 67 | } 68 | } 69 | .padding(.leading) 70 | } 71 | 72 | private var checkmark: some View { 73 | Image(systemName: "checkmark") 74 | .font(.body.bold()) 75 | .opacity(selectedIcon == vm.icon.fileName ? 1 : 0) 76 | } 77 | } 78 | 79 | extension IconRow { 80 | init(_ icon: CustomIcon, selection: Binding) { 81 | self._vm = StateObject(wrappedValue: .init(icon: icon, selection: selection)) 82 | self._selectedIcon = selection 83 | } 84 | } 85 | 86 | fileprivate extension View { 87 | func setDarkModeForIcon() -> some View { 88 | if #unavailable(iOS 18.0) { 89 | return self.environment(\.colorScheme, .light) 90 | } 91 | 92 | return self 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/Filters/FiltersReturnsView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FiltersReturnsView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FiltersReturnsView: View { 11 | @Environment(\.dismiss) 12 | private var dismiss 13 | 14 | @Binding var withReturns: Bool? 15 | 16 | var body: some View { 17 | List { 18 | Section { 19 | Button { 20 | if withReturns != true { 21 | withReturns = true 22 | } 23 | } label: { 24 | HStack { 25 | Text("With returns") 26 | .foregroundColor(.primary) 27 | 28 | Spacer() 29 | 30 | Image(systemName: "checkmark") 31 | .font(.body.bold()) 32 | .opacity(withReturns == true ? 1 : 0) 33 | .animation(.default.speed(3), value: withReturns) 34 | } 35 | } 36 | 37 | Button { 38 | if withReturns != false { 39 | withReturns = false 40 | } 41 | } label: { 42 | HStack { 43 | Text("Without returns") 44 | .foregroundColor(.primary) 45 | 46 | Spacer() 47 | 48 | Image(systemName: "checkmark") 49 | .font(.body.bold()) 50 | .opacity(withReturns == false ? 1 : 0) 51 | .animation(.default.speed(3), value: withReturns) 52 | } 53 | } 54 | } 55 | 56 | Section { 57 | Button("Disable", role: .destructive) { 58 | if withReturns != nil { 59 | withReturns = nil 60 | } 61 | } 62 | .disabled(withReturns == nil) 63 | .animation(.default.speed(3), value: withReturns) 64 | } 65 | } 66 | .navigationTitle("Returns") 67 | .navigationBarTitleDisplayMode(.inline) 68 | .toolbar { 69 | ToolbarItem(placement: .topBarTrailing) { 70 | Button { 71 | dismiss() 72 | } label: { 73 | Text("Done") 74 | .font(.body.bold()) 75 | } 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/DataManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // DataManager.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/05. 6 | // 7 | 8 | import CoreData 9 | 10 | final class DataManager { 11 | static let shared = DataManager() 12 | 13 | let container: NSPersistentContainer 14 | let context: NSManagedObjectContext 15 | lazy private(set) var backgroundContext: NSManagedObjectContext = container.newBackgroundContext() 16 | 17 | init() { 18 | let container = NSPersistentCloudKitContainer(name: "DataContainer") 19 | 20 | if let storeDescription = container.persistentStoreDescriptions.first { 21 | if !NSUbiquitousKeyValueStore.default.bool(forKey: UDKey.iCloudSync.rawValue) { 22 | storeDescription.configuration = "Default" 23 | storeDescription.cloudKitContainerOptions = nil 24 | } 25 | 26 | storeDescription.setOption(true as NSNumber, forKey: NSPersistentHistoryTrackingKey) 27 | storeDescription.setOption(true as NSNumber, forKey: NSPersistentStoreRemoteChangeNotificationPostOptionKey) 28 | } 29 | 30 | container.loadPersistentStores { _, error in 31 | if let error = error { 32 | ErrorType(error: error).publish() 33 | } 34 | } 35 | 36 | #if DEBUG 37 | // do { 38 | // try container.initializeCloudKitSchema() 39 | // } catch { 40 | // print(error) 41 | // } 42 | #endif 43 | 44 | let context = container.viewContext 45 | context.name = "Main context" 46 | context.mergePolicy = NSMergeByPropertyObjectTrumpMergePolicy 47 | context.automaticallyMergesChangesFromParent = true 48 | 49 | try? context.setQueryGenerationFrom(.current) 50 | self.container = container 51 | self.context = context 52 | } 53 | 54 | func save() { 55 | if context.hasChanges { 56 | do { 57 | try context.save() 58 | } catch { 59 | context.rollback() 60 | ErrorType(error: error).publish(file: #fileID, function: #function) 61 | } 62 | } 63 | } 64 | 65 | func deleteSpending(with objectID: NSManagedObjectID) { 66 | guard let object = try? backgroundContext.existingObject(with: objectID) else { 67 | print("Failed") 68 | return 69 | } 70 | 71 | backgroundContext.delete(object) 72 | do { 73 | try backgroundContext.save() 74 | } catch { 75 | ErrorType(error: error).publish() 76 | backgroundContext.rollback() 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/SettingsFormattingView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SettingsFormattingView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/09/03. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct SettingsFormattingView: View { 11 | @EnvironmentObject 12 | private var cdm: CoreDataModel 13 | 14 | @AppStorage(UDKey.formatWithoutTimeZones.rawValue) 15 | private var formatWithoutTimeZones: Bool = false 16 | @AppStorage("timeZoneFormat") 17 | private var timeZoneFormat: Int = 0 18 | 19 | @State 20 | private var isToggled: Bool = false 21 | 22 | var body: some View { 23 | List { 24 | Section { 25 | Toggle("Always Format Without Timezones", isOn: $formatWithoutTimeZones) 26 | } footer: { 27 | Text("format-without-timezones-description-key") 28 | } 29 | 30 | if !formatWithoutTimeZones { 31 | timeZoneFormatSection 32 | } 33 | } 34 | .navigationTitle("Formatting") 35 | .onChange(of: formatWithoutTimeZones) { _ in 36 | if !isToggled { 37 | isToggled = true 38 | } 39 | } 40 | .onDisappear { 41 | if isToggled { 42 | cdm.fetchSpendings() 43 | } 44 | } 45 | .animation(.default, value: formatWithoutTimeZones) 46 | } 47 | 48 | private var timeZoneFormatSection: some View { 49 | Section { 50 | HStack { 51 | Text("Timezone Format") 52 | 53 | Menu { 54 | Picker("Timezone format", selection: $timeZoneFormat) { 55 | ForEach(TimeZone.Format.allCases, id: \.rawValue) { format in 56 | Button {} label: { 57 | if #available(iOS 16.0, *) { 58 | Text(format.localizedName) 59 | 60 | Text(TimeZone.autoupdatingCurrent.formatted(format)) 61 | } else { 62 | Text("\(format.localizedName)\n\(TimeZone.autoupdatingCurrent.formatted(format))") 63 | } 64 | } 65 | .tag(format.rawValue) 66 | } 67 | .pickerStyle(.inline) 68 | } 69 | } label: { 70 | HStack { 71 | Spacer() 72 | 73 | Text("\(TimeZone.Format(rawValue: timeZoneFormat).localizedName)") 74 | } 75 | } 76 | } 77 | } 78 | .animation(.default.speed(2), value: timeZoneFormat) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/Filters/FiltersCurrenciesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FiltersCurrenciesView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FiltersCurrenciesView: View { 11 | @Environment(\.dismiss) 12 | private var dismiss 13 | 14 | @Binding var currencies: [String] 15 | let usedCurrencies: Set 16 | // @EnvironmentObject 17 | // private var cdm: CoreDataModel 18 | 19 | var body: some View { 20 | List { 21 | ForEach(usedCurrencies.sorted(by: <)) { currency in 22 | Button { 23 | rowAction(currency.code) 24 | } label: { 25 | rowLabel(currency) 26 | } 27 | } 28 | 29 | if !usedCurrencies.isEmpty { 30 | Section { 31 | Button("Select All") { 32 | currencies = usedCurrencies.map { $0.code } 33 | } 34 | .disabled(currencies.count == usedCurrencies.count) 35 | 36 | Button("Clear Selection", role: .destructive) { 37 | currencies = [] 38 | } 39 | .disabled(currencies.isEmpty) 40 | .animation(.default.speed(3), value: currencies) 41 | } 42 | } 43 | } 44 | .navigationTitle("Currencies") 45 | .navigationBarTitleDisplayMode(.inline) 46 | .toolbar { 47 | trailingToolbar 48 | } 49 | .overlay { 50 | if usedCurrencies.isEmpty { 51 | CustomContentUnavailableView("No Expenses", imageName: "list.bullet", description: "You can add expenses from home screen.") 52 | } 53 | } 54 | } 55 | 56 | private var trailingToolbar: ToolbarItem { 57 | ToolbarItem(placement: .topBarTrailing) { 58 | Button { 59 | dismiss() 60 | } label: { 61 | Text("Done") 62 | .bold() 63 | } 64 | 65 | } 66 | } 67 | 68 | private func rowLabel(_ currency: Currency) -> some View { 69 | HStack { 70 | Text(currency.name ?? currency.code) 71 | .foregroundColor(.primary) 72 | 73 | Spacer() 74 | 75 | Image(systemName: "checkmark") 76 | .font(.body.bold()) 77 | .opacity(currencies.contains(currency.code) ? 1 : 0) 78 | .animation(.default.speed(3), value: currencies) 79 | } 80 | } 81 | 82 | private func rowAction(_ code: String) { 83 | if let index = currencies.firstIndex(of: code) { 84 | currencies.remove(at: index) 85 | return 86 | } 87 | 88 | currencies.append(code) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /financecontrol/Utils/NetworkMonitor.swift: -------------------------------------------------------------------------------- 1 | // 2 | // NetworkMonitor.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/02. 6 | // 7 | 8 | import Foundation 9 | import Network 10 | import Combine 11 | #if DEBUG 12 | import OSLog 13 | #endif 14 | 15 | // TODO: Doesn't setting up properly on init() 16 | final class NetworkMonitor: ObservableObject { 17 | static let shared = NetworkMonitor() 18 | 19 | let monitor: NWPathMonitor 20 | private let queue: DispatchQueue 21 | 22 | #if DEBUG 23 | let logger: Logger 24 | #endif 25 | var cancellables = Set() 26 | 27 | @Published 28 | var isConnected: Bool 29 | @Published 30 | var isExpensive: Bool 31 | @Published 32 | var status: NWPath.Status 33 | 34 | private init() { 35 | self.monitor = NWPathMonitor() 36 | self.isConnected = monitor.currentPath.status == .satisfied 37 | self.status = monitor.currentPath.status 38 | self.isExpensive = monitor.currentPath.isExpensive 39 | self.queue = DispatchQueue(label: "NetworkMonitor") 40 | 41 | #if DEBUG 42 | self.logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 43 | logToCL() 44 | #endif 45 | sendNotificationOnConnection() 46 | 47 | 48 | monitor.pathUpdateHandler = { path in 49 | DispatchQueue.main.async { 50 | self.isConnected = path.status == .satisfied 51 | self.isExpensive = path.isExpensive 52 | self.status = path.status 53 | } 54 | } 55 | 56 | monitor.start(queue: self.queue) 57 | } 58 | 59 | deinit { 60 | #if DEBUG 61 | logger.log("deinit") 62 | #endif 63 | } 64 | 65 | private func sendNotificationOnConnection() { 66 | self.$isConnected 67 | .receive(on: RunLoop.main) 68 | .sink { [weak self] value in 69 | if value { 70 | NotificationCenter.default.post(name: Notification.Name("ConnectionEstablished"), object: self) 71 | } 72 | } 73 | .store(in: &cancellables) 74 | } 75 | 76 | #if DEBUG 77 | private func logToCL() { 78 | self.$isConnected 79 | .sink { [weak self] value in 80 | self?.logger.debug("\(value.description)") 81 | } 82 | .store(in: &cancellables) 83 | } 84 | #endif 85 | } 86 | 87 | extension NWPath.Status { 88 | var description: String { 89 | switch self { 90 | case .satisfied: 91 | "satisfied" 92 | case .unsatisfied: 93 | "unsatisfied" 94 | case .requiresConnection: 95 | "requiresConnection" 96 | @unknown default: 97 | "unknown" 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /financecontrol/View/Spending/Views/ReturnRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReturnRow.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/20. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ReturnRow: View { 11 | @EnvironmentObject private var cdm: CoreDataModel 12 | @Binding var returnToEdit: ReturnEntity? 13 | let returnEntity: ReturnEntity 14 | let spendingCurrency: String 15 | 16 | var body: some View { 17 | HStack { 18 | VStack(alignment: .leading) { 19 | dateFormat(returnEntity.date ?? .distantPast) 20 | .foregroundColor(.secondary) 21 | .font(.caption) 22 | 23 | Text(returnEntity.amount.formatted(.currency(code: returnEntity.currency ?? spendingCurrency))) 24 | .font(.system(.title3, design: .rounded).bold()) 25 | } 26 | 27 | if let name = returnEntity.name, !name.isEmpty { 28 | Spacer() 29 | 30 | Text(name) 31 | .lineLimit(3) 32 | .padding() 33 | .background { 34 | RoundedRectangle(cornerRadius: 10) 35 | .fill(Color(uiColor: .tertiarySystemGroupedBackground)) 36 | } 37 | } 38 | } 39 | .padding(.vertical, 1) 40 | .normalizePadding() 41 | .foregroundColor(.primary) 42 | .swipeActions(edge: .leading) { 43 | getEditButton(returnEntity) 44 | .labelStyle(.iconOnly) 45 | } 46 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { 47 | getDeleteButton(returnEntity) 48 | .labelStyle(.iconOnly) 49 | } 50 | .contextMenu { 51 | getEditButton(returnEntity) 52 | 53 | getDeleteButton(returnEntity) 54 | } 55 | .onTapGesture { 56 | returnToEdit = returnEntity 57 | } 58 | } 59 | 60 | private func getEditButton(_ entity: ReturnEntity) -> some View { 61 | Button { 62 | returnToEdit = entity 63 | } label: { 64 | Label("Edit", systemImage: "pencil") 65 | } 66 | .tint(.yellow) 67 | } 68 | 69 | private func getDeleteButton(_ entity: ReturnEntity) -> some View { 70 | Button(role: .destructive) { 71 | withAnimation { 72 | cdm.deleteReturn(spendingReturn: entity) 73 | } 74 | } label: { 75 | Label("Delete", systemImage: "trash.fill") 76 | } 77 | .tint(.red) 78 | } 79 | 80 | private func dateFormat(_ date: Date) -> Text { 81 | let dateFormatter = DateFormatter() 82 | dateFormatter.timeStyle = .short 83 | dateFormatter.dateStyle = .medium 84 | dateFormatter.locale = Locale.current 85 | dateFormatter.doesRelativeDateFormatting = true 86 | 87 | return Text(dateFormatter.string(from: date)) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /financecontrol/Utils/Keychain.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Keychain.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/07/22. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Keychain { 11 | let url: String 12 | 13 | init(_ url: String) { 14 | self.url = url 15 | } 16 | 17 | func setPassword(_ key: String, overrideExisting: Bool = false) throws { 18 | guard let keyData = key.data(using: .utf8) else { 19 | throw KeychainError.failedToEncodeURL 20 | } 21 | 22 | if overrideExisting, try self.getPassword() != nil { 23 | try self.removePassword() 24 | } 25 | 26 | let query: [String : Any] = [ 27 | kSecClass as String: kSecClassInternetPassword, 28 | kSecAttrServer as String: url, 29 | kSecValueData as String: keyData 30 | ] 31 | 32 | let status = SecItemAdd(query as CFDictionary, nil) 33 | 34 | guard status == errSecSuccess else { 35 | throw KeychainError.failedToSetValue 36 | } 37 | } 38 | 39 | func getPassword() throws -> String? { 40 | let query: [String:Any] = [ 41 | kSecClass as String: kSecClassInternetPassword, 42 | kSecAttrServer as String: url, 43 | kSecReturnAttributes as String: true, 44 | kSecReturnData as String: true 45 | ] 46 | 47 | var item: CFTypeRef? 48 | let status = SecItemCopyMatching(query as CFDictionary, &item) 49 | guard status == errSecSuccess else { 50 | if status == errSecItemNotFound { 51 | return nil 52 | } 53 | return nil 54 | } 55 | 56 | guard let existingItem = item as? [String:Any], 57 | let apiKeyData = existingItem[kSecValueData as String] as? Data, 58 | let apiKey = String(data: apiKeyData, encoding: .utf8) 59 | else { 60 | throw KeychainError.failedToUnwrapValue 61 | } 62 | 63 | return apiKey 64 | } 65 | 66 | func removePassword() throws { 67 | let query: [String:Any] = [ 68 | kSecClass as String: kSecClassInternetPassword, 69 | kSecAttrServer as String: url 70 | ] 71 | 72 | let status = SecItemDelete(query as CFDictionary) 73 | 74 | guard status == errSecSuccess else { 75 | HapticManager.shared.notification(.error) 76 | throw KeychainError.failedToRemoveValue 77 | } 78 | 79 | HapticManager.shared.notification(.success) 80 | } 81 | } 82 | 83 | enum KeychainError: String, LocalizedError { 84 | case failedToEncodeURL, failedToSetValue, failedToUnwrapValue, failedToRemoveValue 85 | 86 | var errorDescription: String? { 87 | "Keychain error \(self.rawValue)" 88 | } 89 | 90 | var recoverySuggestion: String? { 91 | "Try to restart the app" 92 | } 93 | 94 | var failureReason: String? { 95 | "KeychainError.\(self.rawValue)" 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/ColorAndIconView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ColorAndIconView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ColorAndIconView: View { 11 | @AppStorage(UDKey.color.rawValue) 12 | var defaultColor: String = "Orange" 13 | @AppStorage(UDKey.color.rawValue, store: UserDefaults(suiteName: Vars.groupName)) 14 | var sharedDefaultColor: String = "Orange" 15 | 16 | @State 17 | private var selectedIcon: String? = UIApplication.shared.alternateIconName 18 | 19 | let colors: [(LocalizedStringKey, Color)] = [ 20 | ("Orange", Color.orange), 21 | // ("Red", Color.red), 22 | ("Pink", Color.pink), 23 | ("Purple", Color.purple), 24 | ("Indigo", Color.indigo), 25 | ("Blue", Color.blue), 26 | ("Teal", Color.teal), 27 | ("Mint", Color.mint) 28 | ] 29 | 30 | var body: some View { 31 | List { 32 | colorSection 33 | 34 | iconSection 35 | } 36 | .navigationTitle("Color and Icon") 37 | .navigationBarTitleDisplayMode(.inline) 38 | } 39 | 40 | private var colorSection: some View { 41 | Section { 42 | ForEach(colors, id: \.1) { name, color in 43 | Button { 44 | setColor(color.description.capitalized) 45 | } label: { 46 | HStack { 47 | Text(name) 48 | .foregroundColor(color) 49 | 50 | Spacer() 51 | 52 | Image(systemName: "checkmark") 53 | .font(.body.bold()) 54 | .opacity(defaultColor == color.description.capitalized ? 1 : 0) 55 | } 56 | } 57 | } 58 | // Picker("Color", selection: $defaultColor) { 59 | // ForEach(colors, id: \.1) { name, color in 60 | // Text(name) 61 | // .tag("\(color.description.capitalized)") 62 | // .foregroundColor(color) 63 | // } 64 | // } 65 | // .pickerStyle(.inline) 66 | // .labelsHidden() 67 | } header: { 68 | Text("Accent Color") 69 | } 70 | } 71 | 72 | private var iconSection: some View { 73 | Section { 74 | ForEach(CustomIcon.allCases, id: \.imageName) { icon in 75 | IconRow(icon, selection: $selectedIcon) 76 | } 77 | } header: { 78 | Text("Icon") 79 | } 80 | } 81 | 82 | private func setColor(_ color: String) { 83 | withAnimation { 84 | defaultColor = color 85 | sharedDefaultColor = color 86 | } 87 | WidgetsManager.shared.accentColorChanged = true 88 | } 89 | } 90 | 91 | struct ColorSelector_Previews: PreviewProvider { 92 | static var previews: some View { 93 | ColorAndIconView() 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_Release.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 42 | 44 | 50 | 51 | 52 | 53 | 59 | 61 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /financecontrolWidget/View/SumWidgetView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TotalSpendingsSmallView.swift 3 | // financecontrolWidget 4 | // 5 | // Created by PinkXaciD on R 6/01/11. 6 | // 7 | 8 | import SwiftUI 9 | 10 | #if DEBUG 11 | import WidgetKit 12 | #endif 13 | 14 | @available(*, deprecated, renamed: "WeeklySpendingsWidgetView", message: "This is old widget") 15 | struct SmallSumWidgetView: View { 16 | @Environment(\.colorScheme) private var colorScheme 17 | 18 | let entry: SumEntry 19 | 20 | var body: some View { 21 | if #available(iOS 17, *) { 22 | getNewWidget() 23 | } else { 24 | getOldWidget() 25 | } 26 | } 27 | 28 | private var gradient: LinearGradient { 29 | LinearGradient( 30 | colors: [.upperWidget, .bottomWidget], 31 | startPoint: .top, 32 | endPoint: .bottom 33 | ) 34 | } 35 | 36 | private let upperTextOpacity: CGFloat = 0.7 37 | 38 | private let bottomTextFont: Font = .system(.largeTitle, design: .rounded).bold() 39 | 40 | @available(iOS, introduced: 17, message: "On older systems use getOldWidget()") 41 | private func getNewWidget() -> some View { 42 | HStack { 43 | VStack(alignment: .leading, spacing: 10) { 44 | Text("Today's expenses") 45 | .font(.body) 46 | .opacity(upperTextOpacity) 47 | 48 | Text(entry.expenses.formatted(.currency(code: entry.currency))) 49 | .font(bottomTextFont) 50 | .minimumScaleFactor(0.5) 51 | .privacySensitive() 52 | 53 | Spacer() 54 | } 55 | 56 | Spacer() 57 | } 58 | .foregroundColor(.primary) 59 | .containerBackground(gradient, for: .widget) 60 | } 61 | 62 | @available(iOS, introduced: 14.0, deprecated: 17.0, message: "On newer systems use getNewWidget()") 63 | private func getOldWidget() -> some View { 64 | ZStack { 65 | gradient 66 | 67 | HStack { 68 | VStack(alignment: .leading, spacing: 10) { 69 | Text("Today's expenses") 70 | .font(.subheadline) 71 | .opacity(upperTextOpacity) 72 | 73 | Text(entry.expenses.formatted(.currency(code: entry.currency))) 74 | .font(bottomTextFont) 75 | .minimumScaleFactor(0.5) 76 | .privacySensitive() 77 | 78 | Spacer() 79 | } 80 | 81 | Spacer() 82 | } 83 | .padding() 84 | } 85 | .foregroundColor(.primary) 86 | } 87 | } 88 | 89 | //#if DEBUG 90 | //struct WidgetPreview: PreviewProvider { 91 | // static var previews: some View { 92 | // SmallSumWidgetView(entry: .init(date: .now, expenses: 1200, currency: "JPY")) 93 | // .previewContext(WidgetPreviewContext(family: .systemSmall)) 94 | // } 95 | //} 96 | //#endif 97 | -------------------------------------------------------------------------------- /financecontrol/Extensions/OtherExtensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // OtherExtensions.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/24. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | 11 | extension Bundle { 12 | var releaseVersionNumber: String? { 13 | return infoDictionary?["CFBundleShortVersionString"] as? String 14 | } 15 | 16 | var buildVersionNumber: String? { 17 | return infoDictionary?["CFBundleVersion"] as? String 18 | } 19 | 20 | static let mainIdentifier: String = Bundle.main.bundleIdentifier ?? "dev.squirrelapp.squirrel" 21 | 22 | var displayName: String? { 23 | return object(forInfoDictionaryKey: "CFBundleDisplayName") as? String 24 | } 25 | } 26 | 27 | extension CodingUserInfoKey { 28 | static let moc = CodingUserInfoKey(rawValue: "managedObjectContext")! 29 | } 30 | 31 | extension UIDevice { 32 | var isIPad: Bool { 33 | self.userInterfaceIdiom == .pad 34 | } 35 | 36 | var isIPhone: Bool { 37 | self.userInterfaceIdiom == .phone 38 | } 39 | } 40 | 41 | extension UIApplication { 42 | var keyWindow: UIWindow? { 43 | return self.connectedScenes 44 | .filter { $0.activationState == .foregroundActive } 45 | .first(where: { $0 is UIWindowScene }) 46 | .flatMap({ $0 as? UIWindowScene })?.windows 47 | .first(where: \.isKeyWindow) 48 | } 49 | } 50 | 51 | extension Calendar { 52 | static let gmt: Calendar = { 53 | var calendar: Calendar = .init(identifier: .gregorian) 54 | calendar.locale = .current 55 | var timeZone: TimeZone { 56 | if #available(iOS 16.0, *) { 57 | return .gmt 58 | } 59 | 60 | return .init(secondsFromGMT: 0) ?? .current 61 | } 62 | calendar.timeZone = timeZone 63 | return calendar 64 | }() 65 | } 66 | 67 | extension TimeInterval { 68 | static let hour: Self = 3600 69 | 70 | static let day: Self = 86_400 71 | } 72 | 73 | extension DateFormatter { 74 | static let forRatesTimestamp: DateFormatter = { 75 | let formatter = DateFormatter() 76 | formatter.locale = Locale(identifier: "en_US_POSIX") 77 | formatter.timeZone = .init(secondsFromGMT: 0) 78 | formatter.dateFormat = "yyyy-MM-dd" 79 | return formatter 80 | }() 81 | } 82 | 83 | extension NumberFormatter { 84 | static let standard = NumberFormatter() 85 | 86 | static var currency: NumberFormatter { 87 | let formatter = NumberFormatter() 88 | formatter.maximumFractionDigits = 2 89 | formatter.minimumFractionDigits = 0 90 | formatter.decimalSeparator = Locale.current.decimalSeparator ?? "." 91 | return formatter 92 | } 93 | } 94 | 95 | extension NSNotification.Name { 96 | static let UpdatePieChart = NSNotification.Name("UpdatePieChart") 97 | } 98 | 99 | extension Set { 100 | func cancelAll() { 101 | for item in self { 102 | item.cancel() 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /financecontrolWidget/View/WeeklySpendingsTodaySumView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WeeklySpendingsTodaySumView.swift 3 | // SquirrelWidgetExtension 4 | // 5 | // Created by PinkXaciD on R 6/07/26. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct WeeklySpendingsMediumTodaySumView: View { 11 | let sum: Double 12 | let avg: Double 13 | let currency: String 14 | 15 | var body: some View { 16 | HStack { 17 | VStack(alignment: .leading, spacing: 0) { 18 | Text("Today:") 19 | .font(.footnote) 20 | .foregroundColor(.secondary) 21 | .padding(.bottom, 1) 22 | .privacySensitive() 23 | 24 | VStack(alignment: .leading, spacing: 0) { 25 | Text(currency) 26 | .font(.system(.title3, design: .rounded).bold()) 27 | 28 | Text(Locale.current.currencyNarrowFormat(sum, currency: currency, removeFractionDigigtsFrom: 1000) ?? "Error") 29 | .font(.system(.title, design: .rounded).bold()) 30 | .scaledToFit() 31 | .minimumScaleFactor(0.5) 32 | } 33 | .privacySensitive() 34 | 35 | Divider() 36 | .padding(.vertical, 5) 37 | 38 | Text("Average:") 39 | .font(.caption2) 40 | .foregroundColor(.secondary) 41 | .privacySensitive() 42 | 43 | VStack(alignment: .leading) { 44 | Text(avg.formatted(.currency(code: currency).precision(.fractionLength(0)))) 45 | .font(.system(.body, design: .rounded).bold()) 46 | .scaledToFit() 47 | .minimumScaleFactor(0.5) 48 | } 49 | .privacySensitive() 50 | } 51 | 52 | Spacer() 53 | } 54 | // TODO: Add spending button 55 | // .overlay(alignment: .topTrailing) { 56 | // Image(systemName: "plus.circle.fill") 57 | // .foregroundColor(.accentColor) 58 | // .font(.title2) 59 | // } 60 | } 61 | } 62 | 63 | struct WeeklySpendingsSmallTodaySumView: View { 64 | let currency: String 65 | let sum: Double 66 | 67 | var body: some View { 68 | VStack(alignment: .leading) { 69 | Text("Today:") 70 | .font(.footnote) 71 | .foregroundColor(.secondary) 72 | .privacySensitive() 73 | 74 | Text( 75 | sum.formatted( 76 | .currency(code: currency) 77 | .precision( 78 | .fractionLength(sum >= 1000 ? 0 : Locale.current.currencyFractionDigits(currencyCode: currency)) 79 | ) 80 | ) 81 | ) 82 | .font(.system(.title3, design: .rounded).bold()) 83 | .scaledToFit() 84 | .minimumScaleFactor(0.5) 85 | .privacySensitive() 86 | } 87 | } 88 | } 89 | 90 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Category/ShadowedCategoriesRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ShadowedCatedoryRow.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/18. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct ShadowedCategoriesRow: View { 11 | 12 | // @EnvironmentObject private var cdm: CoreDataModel 13 | @Environment(\.managedObjectContext) private var viewContext 14 | 15 | @State private var alertIsPresented: Bool = false 16 | 17 | let category: CategoryEntity 18 | let safeCategory: TSCategoryEntity 19 | 20 | var body: some View { 21 | categoryInfo 22 | .swipeActions(edge: .trailing, allowsFullSwipe: false) { 23 | getDeleteButton(isSwipeAction: true) 24 | .labelStyle(.iconOnly) 25 | } 26 | .swipeActions(edge: .leading) { 27 | getRestoreButton(isSwipeAction: true) 28 | .labelStyle(.iconOnly) 29 | } 30 | .contextMenu { 31 | getRestoreButton(isSwipeAction: false) 32 | 33 | getDeleteButton(isSwipeAction: false) 34 | } 35 | .alert("Delete this category?", isPresented: $alertIsPresented) { 36 | Button("Delete", role: .destructive) { 37 | withAnimation { 38 | viewContext.delete(category) 39 | try? viewContext.save() 40 | // cdm.deleteCategory(category) 41 | } 42 | } 43 | 44 | Button("Cancel", role: .cancel) {} 45 | } message: { 46 | Text("You can't undo this action.\nAll expenses from this category will be deleted") 47 | } 48 | .normalizePadding() 49 | } 50 | 51 | private var categoryInfo: some View { 52 | HStack { 53 | Image(systemName: "circle.fill") 54 | .font(.title) 55 | .foregroundStyle(.tertiary) 56 | 57 | VStack(alignment: .leading) { 58 | Text(safeCategory.name ?? "Error") 59 | .foregroundStyle(.primary) 60 | } 61 | } 62 | .padding(.vertical, 1) /// Strange behavior without padding 63 | .foregroundStyle(Color.primary, Color.secondary, Color[category.color ?? "nil"]) 64 | } 65 | 66 | private func getDeleteButton(isSwipeAction: Bool) -> some View { 67 | Button(role: isSwipeAction ? nil : .destructive) { 68 | alertIsPresented.toggle() 69 | } label: { 70 | Label("Delete", systemImage: "trash.fill") 71 | } 72 | .tint(Color.red) 73 | } 74 | 75 | private func getRestoreButton(isSwipeAction: Bool) -> some View { 76 | Button(role: isSwipeAction ? .destructive : nil) { 77 | withAnimation { 78 | category.isShadowed.toggle() 79 | try? viewContext.save() 80 | // cdm.changeShadowStateOfCategory(category) 81 | } 82 | } label: { 83 | Label("Restore", systemImage: "arrow.uturn.backward") 84 | } 85 | .tint(Color.green) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_RTL.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 43 | 45 | 51 | 52 | 53 | 54 | 60 | 62 | 68 | 69 | 70 | 71 | 73 | 74 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /financecontrol/Utils/WidgetsManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // WidgetsManager.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/01/09. 6 | // 7 | 8 | import WidgetKit 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | final class WidgetsManager { 14 | static let shared: WidgetsManager = .init() 15 | let sumWidgets: [Widgets] = [.smallSum, .accessorySum, .weeklySpendings, .weeklySpendingsAccessory] 16 | 17 | #if DEBUG 18 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 19 | #endif 20 | 21 | var sumWidgetsNeedsToReload: Bool = false 22 | var accentColorChanged: Bool = false 23 | 24 | private let sharedDefaults = UserDefaults(suiteName: Vars.groupName) 25 | 26 | func reloadAll() { 27 | WidgetCenter.shared.reloadAllTimelines() 28 | } 29 | } 30 | 31 | // MARK: Sum Widgets 32 | extension WidgetsManager { 33 | func reloadSumWidgets() { 34 | if sumWidgetsNeedsToReload { 35 | for widget in sumWidgets { 36 | WidgetCenter.shared.reloadTimelines(ofKind: widget.kind) 37 | // print(widget.kind) 38 | } 39 | sumWidgetsNeedsToReload = false 40 | 41 | #if DEBUG 42 | logger.debug("Reload executed") 43 | #endif 44 | } else { 45 | #if DEBUG 46 | logger.debug("Reload called, but not executed") 47 | #endif 48 | } 49 | } 50 | 51 | func updateSpendingsWidgets(data: [String:Double], amount: Double) { 52 | guard let sharedDefaults else { 53 | #if DEBUG 54 | logger.error("Failed to initialize UserDefaults") 55 | #endif 56 | return 57 | } 58 | 59 | #if DEBUG 60 | // logger.debug("\(data)") 61 | #endif 62 | 63 | let currentDate = Calendar.current.startOfDay(for: .now) 64 | sharedDefaults.set(amount, forKey: "amount") 65 | sharedDefaults.set(currentDate, forKey: "date") 66 | sharedDefaults.set(data, forKey: "WeeklySpendingsWidgetData") 67 | sumWidgetsNeedsToReload = true 68 | } 69 | 70 | func updateAccentColor() { 71 | if accentColorChanged { 72 | let widgetsWithAccentColor: [Widgets] = [.weeklySpendings] 73 | 74 | for widget in widgetsWithAccentColor { 75 | WidgetCenter.shared.reloadTimelines(ofKind: widget.kind) 76 | } 77 | } 78 | } 79 | } 80 | 81 | enum Widgets { 82 | case smallSum, accessorySum, accessoryCircularAddExpense, weeklySpendings, weeklySpendingsAccessory 83 | } 84 | 85 | extension Widgets { 86 | var kind: String { 87 | switch self { 88 | case .smallSum: 89 | "SmallSumWidget" 90 | case .accessorySum: 91 | "AccessorySumWidget" 92 | case .accessoryCircularAddExpense: 93 | "AccessoryCircularAddExpense" 94 | case .weeklySpendings: 95 | "WeeklySpendingsWidget" 96 | case .weeklySpendingsAccessory: 97 | "WeeklySpendingsAccessoryWidget" 98 | } 99 | } 100 | } 101 | // WeeklySpendingsAccessoryWidget, WeeklySpendingsWidget 102 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/CustomContentUnavailableView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomContentUnavailableView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/03/05. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomContentUnavailableView: View { 11 | let imageName: String 12 | let title: LocalizedStringKey 13 | let description: LocalizedStringKey? 14 | let showEffect: Bool 15 | let effectType: CustomSymbolEffect 16 | @State private var animation: Bool = false 17 | @ScaledMetric private var imageSize: CGFloat = 50 18 | 19 | var body: some View { 20 | VStack(spacing: 6) { 21 | Image(systemName: imageName) 22 | .resizable() 23 | .aspectRatio(contentMode: .fit) 24 | .symbolRenderingMode(.monochrome) 25 | .availableSymbolEffect(value: animation, effect: effectType) 26 | .frame(height: imageSize) 27 | .foregroundColor(.secondary) 28 | .padding(15) 29 | 30 | Text(title) 31 | .font(.title2) 32 | .fontWeight(.bold) 33 | 34 | if let description { 35 | Text(description) 36 | .foregroundColor(.secondary) 37 | .font(.callout) 38 | } 39 | } 40 | .onAppear { 41 | animation.toggle() 42 | } 43 | .multilineTextAlignment(.center) 44 | .padding(.horizontal, 30) 45 | } 46 | 47 | init(_ title: LocalizedStringKey, imageName: String = "questionmark", description: LocalizedStringKey? = nil, effect: CustomSymbolEffect = .none) { 48 | self.title = title 49 | self.imageName = imageName 50 | self.description = description 51 | self.showEffect = effect != .none 52 | self.effectType = effect 53 | } 54 | } 55 | 56 | extension CustomContentUnavailableView { 57 | static let search: Self = CustomContentUnavailableView("No results", imageName: "magnifyingglass", description: "Check the spelling or try a new search.") 58 | 59 | static func search(_ text: String) -> CustomContentUnavailableView { 60 | return CustomContentUnavailableView("No results for \"\(text.trimmingCharacters(in: .whitespacesAndNewlines))\"", imageName: "magnifyingglass", description: "Check the spelling or try a new search.") 61 | } 62 | } 63 | 64 | fileprivate extension View { 65 | @ViewBuilder 66 | func availableSymbolEffect(value: T, effect: CustomSymbolEffect = .none) -> some View { 67 | if #available(iOS 17, *) { 68 | switch effect { 69 | case .variableColor: 70 | self 71 | .symbolEffect(.variableColor, options: .repeating, value: value) 72 | case .bounce: 73 | self 74 | .symbolEffect(.bounce.byLayer, value: value) 75 | case .none: 76 | self 77 | } 78 | } else { 79 | self 80 | } 81 | } 82 | } 83 | 84 | enum CustomSymbolEffect { 85 | case variableColor, bounce, none 86 | } 87 | 88 | #if DEBUG 89 | #Preview { 90 | CustomContentUnavailableView("Test", imageName: "archivebox.fill", description: "Description.", effect: .bounce) 91 | } 92 | #endif 93 | -------------------------------------------------------------------------------- /financecontrol/View/Universal/Custom Alert/CustomAlertView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomAlertView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/24. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CustomAlertView: View { 11 | var data: CustomAlertData 12 | @StateObject private var viewModel: CustomAlertViewModel 13 | @State private var animate: Bool = false 14 | 15 | var body: some View { 16 | ZStack { 17 | RoundedRectangle(cornerRadius: 20) 18 | .fill(Material.regular) 19 | .shadow(radius: 5) 20 | 21 | HStack { 22 | Image(systemName: data.systemImage) 23 | .resizable() 24 | .aspectRatio(contentMode: .fit) 25 | .scaleEffect(animate ? 1 : 0.1) 26 | .foregroundColor(data.type.color) 27 | .padding() 28 | 29 | HStack { 30 | VStack(alignment: .leading) { 31 | Text(data.title) 32 | .fontWeight(.bold) 33 | .minimumScaleFactor(0.5) 34 | 35 | if let description = data.description { 36 | Text(description) 37 | .lineLimit(2) 38 | .minimumScaleFactor(0.5) 39 | } 40 | } 41 | .padding(.vertical, 5) 42 | 43 | Spacer() 44 | } 45 | } 46 | } 47 | .frame(maxWidth: .infinity, maxHeight: 80) 48 | .padding(.horizontal) 49 | .onAppear { 50 | withAnimation(.bouncy) { 51 | self.animate = true 52 | } 53 | } 54 | .onTapGesture { 55 | withAnimation(.bouncy) { 56 | CustomAlertManager.shared.removeAlert(data.id) 57 | } 58 | } 59 | .gesture( 60 | DragGesture() 61 | .onChanged { value in 62 | if value.predictedEndTranslation.height < -5 { 63 | withAnimation(.bouncy) { 64 | CustomAlertManager.shared.removeAlert(data.id) 65 | } 66 | } 67 | } 68 | ) 69 | .transition(.opacity.combined(with: .scale).combined(with: .move(edge: .top))) 70 | } 71 | 72 | init(data: CustomAlertData) { 73 | self.data = data 74 | self._viewModel = StateObject(wrappedValue: .init(id: data.id, haptic: data.type.haptic)) 75 | } 76 | } 77 | 78 | #if DEBUG 79 | #Preview { 80 | VStack { 81 | CustomAlertView(data: .init(type: .error, title: "Error", description: "Description.", systemImage: "xmark.circle")) 82 | CustomAlertView(data: .init(type: .warning, title: "Warning", description: "Description.", systemImage: "exclamationmark.circle")) 83 | CustomAlertView(data: .init(type: .success, title: "Success", description: "Description.", systemImage: "checkmark.circle")) 84 | CustomAlertView(data: .init(type: .info, title: "Info", description: "Description.", systemImage: "questionmark.circle")) 85 | } 86 | } 87 | #endif 88 | -------------------------------------------------------------------------------- /financecontrol/View/Selectors/CustomColorSelector.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CustomColorSelector.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/09/09. 6 | // 7 | 8 | import Foundation 9 | import SwiftUI 10 | 11 | struct CustomColorSelector: View { 12 | 13 | @Binding var colorSelectedDescription: String 14 | let colors = CustomColor.nordAurora 15 | 16 | var body: some View { 17 | 18 | let columns: [GridItem] = Array(repeating: .init(.flexible(minimum: 35, maximum: 50), spacing: 10, alignment: .center), count: 7) 19 | 20 | LazyVGrid(columns: columns) { 21 | ForEach(colors.compactMap{$0.key}.sorted{$0 < $1}, id: \.self) { colorDescription in 22 | Button { 23 | buttonAction(colorDescription) 24 | } label: { 25 | if #available(iOS 26.0, *) { 26 | newButtonLabel(colorDescription) 27 | } else { 28 | buttonLabel(colorDescription) 29 | } 30 | } 31 | .buttonStyle(.plain) 32 | .contentShape(.hoverEffect, Circle()) 33 | .hoverEffect(.lift) 34 | } 35 | } 36 | } 37 | 38 | private func buttonLabel(_ colorDescription: String) -> some View { 39 | Circle() 40 | .fill(colors[colorDescription] ?? .black) 41 | .overlay { 42 | Circle() 43 | .stroke(lineWidth: colorDescription == colorSelectedDescription ? 3 : 0) 44 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground)) 45 | .opacity(colorDescription == colorSelectedDescription ? 1 : 0) 46 | .scaleEffect(colorDescription == colorSelectedDescription ? 0.8 : 1) 47 | } 48 | .frame(minWidth: 35, maxWidth: 50, minHeight: 35, maxHeight: 50) 49 | } 50 | 51 | @available(iOS 26.0, *) 52 | private func newButtonLabel(_ colorDescription: String) -> some View { 53 | Circle() 54 | .fill(colors[colorDescription] ?? .black) 55 | .overlay { 56 | Circle() 57 | .stroke(lineWidth: colorDescription == colorSelectedDescription ? 3 : 0) 58 | .foregroundColor(Color(uiColor: .secondarySystemGroupedBackground)) 59 | .opacity(colorDescription == colorSelectedDescription ? 1 : 0) 60 | .scaleEffect(colorDescription == colorSelectedDescription ? 0.8 : 1) 61 | } 62 | .glassEffect(.regular, in: Circle()) 63 | .frame(minWidth: 35, maxWidth: 50, minHeight: 35, maxHeight: 50) 64 | } 65 | 66 | private func buttonAction(_ colorDescription: String) { 67 | withAnimation(.bouncy) { 68 | colorSelectedDescription = colorDescription 69 | } 70 | } 71 | } 72 | 73 | struct CustomColorSelectorPreviews: PreviewProvider { 74 | static var previews: some View { 75 | CustomColorSelectorPreview() 76 | } 77 | } 78 | 79 | fileprivate struct CustomColorSelectorPreview: View { 80 | @State var colorSelectedDescription: String = "nordRed" 81 | 82 | var body: some View { 83 | List { 84 | CustomColorSelector(colorSelectedDescription: $colorSelectedDescription) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Category/CategoryRow.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoryRow.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct CategoryRow: View { 11 | 12 | // @EnvironmentObject private var cdm: CoreDataModel 13 | @Environment(\.managedObjectContext) private var viewContext 14 | 15 | @ObservedObject 16 | var category: CategoryEntity 17 | 18 | var body: some View { 19 | 20 | NavigationLink { 21 | CategoryEditView(category: category) 22 | } label: { 23 | navLinkLabel 24 | } 25 | .swipeActions(edge: .leading) { 26 | favoriteButton 27 | .labelStyle(.iconOnly) 28 | } 29 | .swipeActions(edge: .trailing) { 30 | getDeleteButton(isSwipeAction: true) 31 | .labelStyle(.iconOnly) 32 | } 33 | .contextMenu { 34 | favoriteButton 35 | 36 | getDeleteButton(isSwipeAction: false) 37 | } 38 | .normalizePadding() 39 | .animation(.default, value: category.isFavorite) 40 | } 41 | 42 | private var navLinkLabel: some View { 43 | let spendingsCount: Int = category.spendings?.count ?? 0 44 | 45 | return HStack { 46 | Image(systemName: category.isFavorite ? "star.circle.fill" : "circle.fill") 47 | .font(.title) 48 | .foregroundStyle(Color[category.color ?? "nil"]) 49 | 50 | VStack(alignment: .leading) { 51 | Text(category.name ?? "Error") 52 | .foregroundStyle(.primary) 53 | 54 | Text("\(spendingsCount) expenses") 55 | .font(.footnote) 56 | .foregroundStyle(.secondary) 57 | } 58 | 59 | Spacer() 60 | 61 | Text("Edit") 62 | .foregroundStyle(.secondary) 63 | } 64 | // .foregroundStyle(Color.primary, Color.secondary, Color[category.color ?? "nil"]) 65 | .padding(.vertical, 1) /// Strange behavior without padding 66 | } 67 | 68 | private var favoriteButton: some View { 69 | Button { 70 | // withAnimation { 71 | category.isFavorite.toggle() 72 | try? viewContext.save() 73 | // cdm.changeFavoriteStateOfCategory(category) 74 | // } 75 | } label: { 76 | Label( 77 | category.isFavorite ? "Remove from favorites" : "Add to favorites", 78 | systemImage: category.isFavorite ? "star.slash.fill" : "star.fill" 79 | ) 80 | } 81 | .tint(.yellow) 82 | } 83 | 84 | private func getDeleteButton(isSwipeAction: Bool) -> some View { 85 | Button(role: isSwipeAction ? .destructive : nil) { 86 | withAnimation { 87 | category.isShadowed.toggle() 88 | try? viewContext.save() 89 | // cdm.changeShadowStateOfCategory(category) 90 | } 91 | } label: { 92 | Label("Archive", systemImage: "archivebox.fill") 93 | } 94 | .tint(.gray) 95 | } 96 | } 97 | 98 | //#Preview { 99 | // CategoryRow() 100 | //} 101 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/Filters/FiltersCategoriesView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FiltersCategoriesView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/06/12. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct FiltersCategoriesView: View { 11 | @Environment(\.dismiss) 12 | private var dismiss 13 | 14 | @Binding 15 | var categories: [UUID] 16 | @Binding 17 | var applyFilters: Bool 18 | 19 | // let listData: [CategoryEntity] 20 | @FetchRequest(sortDescriptors: [NSSortDescriptor(key: "name", ascending: true)]) 21 | private var fetchedCategories: FetchedResults 22 | 23 | var body: some View { 24 | List { 25 | Section { 26 | ForEach(fetchedCategories) { category in 27 | Button { 28 | categoryButtonAction(category) 29 | } label: { 30 | categoryRowLabel(category) 31 | } 32 | } 33 | } 34 | 35 | if !fetchedCategories.isEmpty { 36 | Section { 37 | Button("Select All") { 38 | categories = fetchedCategories.map { $0.id ?? .init() } 39 | } 40 | .disabled(categories.count == fetchedCategories.count) 41 | 42 | Button("Clear Selection", role: .destructive) { 43 | categories.removeAll() 44 | } 45 | .disabled(categories.isEmpty) 46 | .animation(.default.speed(2), value: categories) 47 | } 48 | } 49 | } 50 | .navigationTitle("Categories") 51 | .toolbar { 52 | trailingToolbar 53 | } 54 | .overlay { 55 | if fetchedCategories.isEmpty { 56 | CustomContentUnavailableView("No Categories", imageName: "list.bullet", description: "You can add categories in settings.") 57 | } 58 | } 59 | } 60 | 61 | private var trailingToolbar: ToolbarItem { 62 | ToolbarItem { 63 | Button("Done") { 64 | dismiss() 65 | } 66 | .font(.body.bold()) 67 | } 68 | } 69 | 70 | private func categoryButtonAction(_ category: CategoryEntity) { 71 | guard let id = category.id else { 72 | return 73 | } 74 | 75 | if categories.contains(id) { 76 | let index: Int = categories.firstIndex(of: id) ?? 0 77 | categories.remove(at: index) 78 | } else { 79 | categories.append(id) 80 | } 81 | } 82 | 83 | private func categoryRowLabel(_ category: CategoryEntity) -> some View { 84 | return HStack { 85 | Image(systemName: "circle.fill") 86 | .foregroundColor(Color[category.color ?? ""]) 87 | .font(.title) 88 | 89 | Text(category.name ?? "Error") 90 | .foregroundColor(.primary) 91 | 92 | Spacer() 93 | 94 | Image(systemName: "checkmark") 95 | .font(.body.bold()) 96 | .opacity(categories.contains(category.id ?? .init()) ? 1 : 0) 97 | .animation(.default.speed(3), value: categories) 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/Entities/CategoryEntity/CategoryEntity+CoreDataClass.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoryEntity+CoreDataClass.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/31. 6 | // 7 | // 8 | 9 | import Foundation 10 | import CoreData 11 | #if DEBUG 12 | import OSLog 13 | #endif 14 | 15 | @objc(CategoryEntity) 16 | public final class CategoryEntity: NSManagedObject, Codable { 17 | enum CodingKeys: CodingKey { 18 | case id, color, isShadowed, isFavorite, name, spendings 19 | } 20 | 21 | override public init(entity: NSEntityDescription, insertInto context: NSManagedObjectContext?) { 22 | super.init(entity: entity, insertInto: context) 23 | } 24 | 25 | required convenience public init(from decoder: Decoder) throws { 26 | guard let context = decoder.userInfo[.moc] as? NSManagedObjectContext else { 27 | throw URLError(.badURL) 28 | } 29 | 30 | self.init(context: context) 31 | let container = try decoder.container(keyedBy: CodingKeys.self) 32 | 33 | // Required fields 34 | do { 35 | self.id = try container.decode(UUID.self, forKey: .id) 36 | self.color = try container.decode(String.self, forKey: .color) 37 | self.name = try container.decode(String.self, forKey: .name) 38 | } catch { 39 | #if DEBUG 40 | Logger(subsystem: Vars.appIdentifier, category: #fileID).error("\(error)") 41 | #endif 42 | throw error 43 | } 44 | 45 | // Optional fields 46 | do { 47 | self.isShadowed = try container.decode(Bool.self, forKey: .isShadowed) 48 | } catch let DecodingError.keyNotFound(_, context) { 49 | self.isShadowed = false 50 | 51 | #if DEBUG 52 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 53 | logger.debug("\(context.debugDescription)") 54 | #endif 55 | } 56 | 57 | do { 58 | self.isFavorite = try container.decode(Bool.self, forKey: .isFavorite) 59 | } catch let DecodingError.keyNotFound(_, context) { 60 | self.isFavorite = false 61 | 62 | #if DEBUG 63 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 64 | logger.debug("\(context.debugDescription)") 65 | #endif 66 | } 67 | 68 | do { 69 | self.spendings = try container.decode(Set.self, forKey: .spendings) as NSSet 70 | } catch let DecodingError.keyNotFound(_, context) { 71 | self.spendings = Set() as NSSet 72 | 73 | #if DEBUG 74 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 75 | logger.debug("\(context.debugDescription)") 76 | #endif 77 | } 78 | } 79 | 80 | public func encode(to encoder: Encoder) throws { 81 | var container = encoder.container(keyedBy: CodingKeys.self) 82 | try container.encode(id, forKey: .id) 83 | try container.encode(color, forKey: .color) 84 | try container.encode(isShadowed, forKey: .isShadowed) 85 | try container.encode(isFavorite, forKey: .isFavorite) 86 | try container.encode(name, forKey: .name) 87 | if let array = spendings?.allObjects as? [SpendingEntity] { 88 | try container.encode(array, forKey: .spendings) 89 | } 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/ViewModel/PieChartViewModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PieChartLazyPageViewViewModel.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/02/03. 6 | // 7 | 8 | import SwiftUI 9 | import Combine 10 | #if DEBUG 11 | import OSLog 12 | #endif 13 | 14 | final class PieChartViewModel: ViewModel { 15 | private var cdm: CoreDataModel 16 | var fvm: FiltersViewModel 17 | 18 | @Published var selection: Int = 0 19 | @Published var data: [ChartData] 20 | @Published var selectedCategory: ChartCategory? = nil 21 | @Published var isScrollDisabled: Bool = false 22 | @Published var showOther: Bool = false 23 | 24 | private var filtersWereEnabled: Bool = false 25 | 26 | var cancellables = Set() 27 | let id = UUID() 28 | 29 | init(cdm: CoreDataModel, fvm: FiltersViewModel) { 30 | self.cdm = cdm 31 | self.fvm = fvm 32 | 33 | self.data = cdm.getNewChartData() 34 | 35 | NotificationCenter.default.addObserver(self, selector: #selector(updateData), name: .UpdatePieChart, object: nil) 36 | 37 | #if DEBUG 38 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 39 | logger.debug("ViewModel initialized") 40 | #endif 41 | } 42 | 43 | deinit { 44 | #if DEBUG 45 | let logger = Logger(subsystem: Vars.appIdentifier, category: #fileID) 46 | logger.debug("ViewModel deinitialized") 47 | #endif 48 | NotificationCenter.default.removeObserver(self, name: .init("UpdatePieChart"), object: nil) 49 | } 50 | } 51 | 52 | // MARK: Methods 53 | extension PieChartViewModel { 54 | @objc func updateData() { 55 | DispatchQueue.main.async { [weak self] in 56 | guard let self else { return } 57 | 58 | let chartData: [ChartData] = { 59 | if self.fvm.applyFilters { 60 | if !self.filtersWereEnabled { 61 | self.selection = 0 62 | self.isScrollDisabled = true 63 | self.selectedCategory = nil 64 | self.filtersWereEnabled = true 65 | } 66 | 67 | return self.cdm.getNewFilteredChartData( 68 | firstDate: self.fvm.startFilterDate, 69 | secondDate: self.fvm.endFilterDate, 70 | categories: self.fvm.filterCategories, 71 | withReturns: self.fvm.withReturns, 72 | currencies: self.fvm.currencies 73 | ) 74 | } 75 | 76 | if self.filtersWereEnabled { 77 | self.selectedCategory = nil 78 | self.isScrollDisabled = false 79 | self.filtersWereEnabled = false 80 | } 81 | 82 | return self.cdm.getNewChartData() 83 | }() 84 | 85 | self.data = chartData 86 | } 87 | } 88 | 89 | func applyFilters() { 90 | self.selection = 0 91 | self.isScrollDisabled = true 92 | self.selectedCategory = nil 93 | self.updateData() 94 | } 95 | 96 | func disableFilters() { 97 | self.selectedCategory = nil 98 | self.updateData() 99 | self.isScrollDisabled = false 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/DataContainer.xcdatamodeld/SpendingsModel.xcdatamodel/contents: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/StatsListView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StatsListView.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on R 6/02/16. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct StatsListView: View { 11 | @EnvironmentObject private var fvm: FiltersViewModel 12 | @EnvironmentObject private var vm: StatsListViewModel 13 | @EnvironmentObject private var searchModel: StatsSearchViewModel 14 | @EnvironmentObject private var statsViewModel: StatsViewModel 15 | 16 | @StateObject 17 | private var rowVM = StatsRowViewModel() 18 | 19 | @GestureState 20 | private var draggingRow: UUID? = nil 21 | 22 | @Binding 23 | var spendingToDelete: SpendingEntity? 24 | @Binding 25 | var presentDeleteDialog: Bool 26 | 27 | private var headerFont: Font { 28 | if #available(iOS 26.0, *) { 29 | return .body.bold() 30 | } 31 | 32 | return .subheadline.bold() 33 | } 34 | 35 | var body: some View { 36 | if !vm.data.isEmpty { 37 | list 38 | } else { 39 | noResults 40 | .padding(.vertical) 41 | } 42 | } 43 | 44 | private var list: some View { 45 | ForEach(vm.data, id: \.key) { section in 46 | VStack(alignment: .leading) { 47 | dateFormatForList(section.key) 48 | .textCase(nil) 49 | .font(headerFont) 50 | .foregroundStyle(.secondary) 51 | .padding(.horizontal) 52 | .padding(.top) 53 | 54 | VStack(spacing: 0) { 55 | ForEach(section.value) { spending in 56 | StatsRow(state: $draggingRow, data: spending, spendingToDelete: $spendingToDelete, presentDeleteDialog: $presentDeleteDialog) 57 | .environmentObject(rowVM) 58 | 59 | if spending.id != section.value.last?.id { 60 | Divider() 61 | } 62 | } 63 | } 64 | .clipShape(RoundedRectangle(cornerRadius: Self.listCornerRadius)) 65 | } 66 | } 67 | } 68 | 69 | private var noResults: some View { 70 | if !searchModel.search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 71 | CustomContentUnavailableView.search(searchModel.search.trimmingCharacters(in: .whitespacesAndNewlines)) 72 | } else if fvm.applyFilters { 73 | CustomContentUnavailableView("No Results for These Filters", imageName: "tray.fill") 74 | } else if vm.selection != 0 { 75 | CustomContentUnavailableView("No Expenses in This Month", imageName: "list.bullet", description: "You can add expenses from home screen.") 76 | } else { 77 | CustomContentUnavailableView("No Expenses", imageName: "list.bullet", description: "You can add expenses from home screen.") 78 | } 79 | } 80 | 81 | private func dateFormatForList(_ date: Date) -> Text { 82 | if Calendar.current.isDateInToday(date) { 83 | return Text("Today") 84 | } else if Calendar.current.isDateInYesterday(date) { 85 | return Text("Yesterday") 86 | } else if Calendar.current.isDateInTomorrow(date) { 87 | return Text("Tomorrow") 88 | } else if Calendar.current.isDate(date, equalTo: Date(), toGranularity: .year) { 89 | return Text(date, format: .dateTime.day().month(.wide)) 90 | } else { 91 | return Text(date, format: .dateTime.day().month(.wide).year()) 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/Entities/CategoryEntity/CategoriesModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CategoriesModel.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/09/07. 6 | // 7 | 8 | import CoreData 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | extension CoreDataModel { 14 | 15 | func fetchCategories() { 16 | 17 | // let request = CategoryEntity.fetchRequest() 18 | // request.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] 19 | // request.predicate = NSPredicate(format: "isShadowed == false") 20 | // 21 | // let requestForShadowed = CategoryEntity.fetchRequest() 22 | // requestForShadowed.sortDescriptors = [NSSortDescriptor(key: "name", ascending: true)] 23 | // requestForShadowed.predicate = NSPredicate(format: "isShadowed == true") 24 | // 25 | // do { 26 | // savedCategories = try context.fetch(request) 27 | // shadowedCategories = try context.fetch(requestForShadowed) 28 | // } catch { 29 | // ErrorType(error: error).publish() 30 | // } 31 | } 32 | 33 | func findCategory(_ id: UUID, in context: NSManagedObjectContext = DataManager.shared.context) -> CategoryEntity? { 34 | context.performAndWait { 35 | let request = CategoryEntity.fetchRequest() 36 | request.predicate = NSPredicate(format: "id == %@", id as CVarArg) 37 | 38 | do { 39 | return try context.fetch(request).first 40 | } catch { 41 | #if DEBUG 42 | let logger = Logger(subsystem: Vars.appIdentifier, category: "CoreDataModel") 43 | logger.error("Error finding category: \(error)") 44 | #endif 45 | return nil 46 | } 47 | } 48 | } 49 | 50 | func addCategory(name: String, color: String) -> CategoryEntity? { 51 | 52 | if let description = NSEntityDescription.entity(forEntityName: "CategoryEntity", in: context) { 53 | 54 | let newCategory = CategoryEntity(entity: description, insertInto: context) 55 | 56 | let id = UUID() 57 | newCategory.id = id 58 | newCategory.name = name 59 | newCategory.color = color 60 | newCategory.isShadowed = false 61 | 62 | manager.save() 63 | fetchCategories() 64 | 65 | return newCategory 66 | } 67 | 68 | return nil 69 | } 70 | 71 | func addToCategory(_ spending: SpendingEntity, _ category: CategoryEntity) { 72 | 73 | category.addToSpendings(spending) 74 | } 75 | 76 | func editCategory(_ category: CategoryEntity, name: String, color: String) { 77 | 78 | category.name = name 79 | category.color = color 80 | manager.save() 81 | fetchCategories() 82 | fetchSpendings() 83 | } 84 | 85 | func changeShadowStateOfCategory(_ category: CategoryEntity) { 86 | 87 | category.isShadowed.toggle() 88 | category.isFavorite = false 89 | manager.save() 90 | fetchCategories() 91 | } 92 | 93 | func changeFavoriteStateOfCategory(_ category: CategoryEntity) { 94 | 95 | category.isFavorite.toggle() 96 | manager.save() 97 | fetchCategories() 98 | } 99 | 100 | func deleteCategory(_ category: CategoryEntity) { 101 | 102 | context.delete(category) 103 | manager.save() 104 | fetchCategories() 105 | fetchSpendings() 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Currency/AddCurrencyView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddCurrencyView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/08/04. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AddCurrencyView: View { 11 | @EnvironmentObject private var cdm: CoreDataModel 12 | 13 | @State private var search: String = "" 14 | let currencyCodes = Dictionary(grouping: Locale.customCommonISOCurrencyCodes) { code in 15 | (Locale.current.localizedString(forCurrencyCode: code) ?? code).prefix(1).capitalized 16 | } 17 | 18 | var body: some View { 19 | Group { 20 | let searchResult = searchFunc() 21 | 22 | List { 23 | ForEach(Array(searchResult.keys).sorted(), id: \.self) { key in 24 | Section { 25 | if let currencies = searchResult[key] { 26 | let mappedCurrencies = currencies.map { (code: $0, name: Locale.current.localizedString(forCurrencyCode: $0) ?? $0) } 27 | 28 | ForEach(mappedCurrencies.sorted { $0.name < $1.name }, id: \.code) { currency in 29 | NewCurrencyRow(name: currency.name, code: currency.code) 30 | } 31 | } 32 | } header: { 33 | Text(key) 34 | } 35 | } 36 | } 37 | .overlay { 38 | if searchResult.isEmpty { 39 | if search.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { 40 | CustomContentUnavailableView("No currencies", imageName: "questionmark", description: "No currencies found. Maybe you have added all of them?") 41 | } else { 42 | CustomContentUnavailableView.search(search.trimmingCharacters(in: .whitespacesAndNewlines)) 43 | } 44 | } 45 | } 46 | } 47 | .searchable( 48 | text: $search, 49 | placement: getSearchBarPlacement(), 50 | // placement: .automatic, iOS 26 51 | prompt: "Currency name or ISO Code" 52 | ) 53 | .navigationTitle("Add Currency") 54 | } 55 | 56 | private func excludeAdded(_ data: [String]) -> [String] { 57 | let removeSet: Set = Set(UserDefaults.standard.getRawCurrencies()) 58 | return data.filter { !removeSet.contains($0) } 59 | } 60 | 61 | private func searchFunc() -> [String : [String]] { 62 | let trimmedSearch = search.trimmingCharacters(in: .whitespacesAndNewlines) 63 | 64 | if trimmedSearch.isEmpty { 65 | return currencyCodes 66 | .mapValues { excludeAdded($0) } 67 | .filter { !$0.value.isEmpty } 68 | } else { 69 | return currencyCodes.mapValues { values in 70 | excludeAdded(values).filter { value in 71 | value.localizedCaseInsensitiveContains(trimmedSearch) || (Locale.current.localizedString(forCurrencyCode: value) ?? "").localizedCaseInsensitiveContains(trimmedSearch) 72 | } 73 | } 74 | .filter { !$0.value.isEmpty } 75 | } 76 | } 77 | 78 | private func getSearchBarPlacement() -> SearchFieldPlacement { 79 | if #available(iOS 26.0, *) { 80 | return .automatic 81 | } 82 | 83 | return .navigationBarDrawer(displayMode: .always) 84 | } 85 | } 86 | 87 | struct AddCurrencyView_Previews { 88 | static var previews: some View { 89 | AddCurrencyView() 90 | .environmentObject(CoreDataModel()) 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /financecontrol/Error/ErrorHandler.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ErrorHandler.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/10/08. 6 | // 7 | 8 | import Foundation 9 | #if DEBUG 10 | import OSLog 11 | #endif 12 | 13 | final class ErrorHandler: ObservableObject { 14 | static let shared: ErrorHandler = .init() 15 | 16 | @Published var appError: ErrorType? = nil 17 | @Published var showAlert: Bool = false 18 | 19 | func dropError() { 20 | appError = nil 21 | showAlert = false 22 | } 23 | } 24 | 25 | struct ErrorType: Identifiable, Equatable { 26 | let id: UUID = .init() 27 | let errorDescription: String 28 | let failureReason: String 29 | let recoverySuggestion: String 30 | let helpAnchor: String 31 | var createIssue: Bool = true 32 | 33 | init(error: Error) { 34 | self.errorDescription = "Unknown error: \(error.localizedDescription)" 35 | self.failureReason = error.localizedDescription 36 | self.recoverySuggestion = "Please submit a bug report and try to restart the app" 37 | self.helpAnchor = "" 38 | } 39 | 40 | init(_ localizedError: LocalizedError) { 41 | self.errorDescription = localizedError.errorDescription ?? "" 42 | self.failureReason = localizedError.failureReason ?? "" 43 | self.recoverySuggestion = localizedError.recoverySuggestion ?? "" 44 | self.helpAnchor = localizedError.helpAnchor ?? "" 45 | } 46 | 47 | init(_ urlError: URLError) { 48 | switch urlError { 49 | case URLError(.badURL): 50 | self.errorDescription = "Can't reach requested URL" 51 | self.failureReason = urlError.localizedDescription 52 | self.recoverySuggestion = "Try to restart the app" 53 | self.helpAnchor = "" 54 | 55 | case URLError(.badServerResponse): 56 | self.errorDescription = "Squirrel servers did not response correctly" 57 | self.failureReason = urlError.localizedDescription 58 | self.recoverySuggestion = "Try to restart the app" 59 | self.helpAnchor = "" 60 | 61 | case URLError(.notConnectedToInternet): 62 | self.errorDescription = urlError.localizedDescription 63 | self.failureReason = urlError.localizedDescription 64 | self.recoverySuggestion = "Check your internet connection" 65 | self.helpAnchor = "" 66 | self.createIssue = false 67 | 68 | default: 69 | self.errorDescription = urlError.localizedDescription 70 | self.failureReason = urlError.localizedDescription 71 | self.recoverySuggestion = "Try to restart the app" 72 | self.helpAnchor = "" 73 | } 74 | } 75 | 76 | init(errorDescription: String, failureReason: String, recoverySuggestion: String, helpAnchor: String = "") { 77 | self.errorDescription = errorDescription 78 | self.failureReason = failureReason 79 | self.recoverySuggestion = recoverySuggestion 80 | self.helpAnchor = helpAnchor 81 | self.createIssue = false 82 | } 83 | 84 | func publish(file: String? = nil, function: String? = nil) { 85 | #if DEBUG 86 | let fileString = file != nil ? ", in file \(file ?? "")" : "" 87 | let funcString = function != nil ? ", in function \(function ?? "")" : "" 88 | let logger = Logger(subsystem: Vars.appIdentifier, category: "errors") 89 | logger.error("Failure reason: \(self.failureReason)\(fileString)\(funcString), occured at \(Date.now.formatted(date: .numeric, time: .standard))") 90 | #endif 91 | 92 | ErrorHandler.shared.appError = self 93 | ErrorHandler.shared.showAlert = true 94 | HapticManager.shared.notification(.error) 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_Debug.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /Squirrel.xcodeproj/xcshareddata/xcschemes/Squirrel_CoreDataDebug.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 9 | 10 | 16 | 22 | 23 | 24 | 25 | 26 | 32 | 33 | 43 | 45 | 51 | 52 | 53 | 54 | 57 | 58 | 61 | 62 | 63 | 64 | 68 | 69 | 70 | 71 | 77 | 79 | 85 | 86 | 87 | 88 | 90 | 91 | 94 | 95 | 96 | -------------------------------------------------------------------------------- /financecontrol/View/Stats/Views/PieChartLegendView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // PieChartLegendView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 6/02/03. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct PieChartLegendView: View { 11 | @EnvironmentObject 12 | private var pcvm: PieChartViewModel 13 | 14 | @Binding 15 | var minimize: Bool 16 | @Binding 17 | var selection: Int 18 | 19 | private var verticalPadding: CGFloat { 20 | if #available(iOS 26, *) { 21 | return 14 22 | } 23 | 24 | return 10 25 | } 26 | 27 | var body: some View { 28 | let data = pcvm.data[(selection >= pcvm.data.count || selection < 0) ? 0 : selection] 29 | 30 | if !data.categories.isEmpty, pcvm.selectedCategory == nil { 31 | Divider() 32 | } 33 | 34 | if let selectedCategory = pcvm.selectedCategory, !(data.categoriesDict[selectedCategory.id]?.places ?? []).isEmpty { 35 | Divider() 36 | } 37 | 38 | Group { 39 | if minimize { 40 | ScrollView(.horizontal, showsIndicators: false) { 41 | WrappedGlassEffectContainer { 42 | HStack(spacing: 10) { 43 | content 44 | } 45 | .padding(.horizontal, 20) 46 | .padding(.vertical, verticalPadding) 47 | } 48 | } 49 | .font(.system(size: 14)) 50 | .transaction { transaction in 51 | transaction.animation = nil 52 | } 53 | .transition(.opacity) 54 | } else { 55 | HStack(spacing: 0) { 56 | WrappedGlassEffectContainer { 57 | VStack(alignment: .leading, spacing: 10) { 58 | content 59 | } 60 | } 61 | 62 | Spacer() 63 | } 64 | .font(.system(size: 14)) 65 | .padding(.horizontal, 20) 66 | .padding(.vertical, verticalPadding) 67 | .transaction { transaction in 68 | transaction.animation = nil 69 | } 70 | .transition(.maskFromTheBottomWithOpacity) 71 | } 72 | } 73 | } 74 | 75 | @ViewBuilder 76 | private var content: some View { 77 | let data = pcvm.data[(selection >= pcvm.data.count || selection < 0) ? 0 : selection] 78 | 79 | if let selectedCategory = pcvm.selectedCategory { 80 | ForEach(data.categoriesDict[selectedCategory.id]?.places ?? []) { place in 81 | PieChartLegendRowView(category: place) 82 | } 83 | } else { 84 | ForEach(data.categories) { category in 85 | PieChartLegendRowView(category: category) 86 | } 87 | 88 | if let otherCategory = data.otherCategory, !pcvm.showOther { 89 | PieChartLegendRowView(category: otherCategory) 90 | } 91 | 92 | if pcvm.showOther { 93 | ForEach(data.otherCategories) { category in 94 | PieChartLegendRowView(category: category) 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | fileprivate struct WrappedGlassEffectContainer: View where Content: View { 102 | let content: () -> Content 103 | 104 | var body: some View { 105 | if #available(iOS 26.0, *) { 106 | GlassEffectContainer { 107 | content() 108 | } 109 | } else { 110 | content() 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /financecontrol/Data/CoreData/Entities/ReturnEntity/ReturnsModel.swift: -------------------------------------------------------------------------------- 1 | // 2 | // ReturnsModel.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/11/30. 6 | // 7 | 8 | import CoreData 9 | 10 | extension CoreDataModel { 11 | func addReturn(to spending: SpendingEntity, amount: Double, amountUSD: Double, currency: String, date: Date, name: String) { 12 | guard 13 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context) 14 | else { 15 | return 16 | } 17 | 18 | let newReturn: ReturnEntity = .init(entity: description, insertInto: context) 19 | newReturn.id = .init() 20 | newReturn.amount = amount 21 | newReturn.amountUSD = amountUSD 22 | newReturn.currency = currency 23 | newReturn.date = date 24 | newReturn.name = name 25 | 26 | spending.addToReturns(newReturn) 27 | manager.save() 28 | 29 | fetchSpendings() 30 | 31 | HapticManager.shared.notification(.success) 32 | } 33 | 34 | func importReturn(to spending: SpendingEntity, returnEntity: ReturnEntity) { 35 | guard 36 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context) 37 | else { 38 | ErrorType(CoreDataError.failedToGetEntityDescription).publish() 39 | return 40 | } 41 | 42 | let newReturn: ReturnEntity = .init(entity: description, insertInto: context) 43 | newReturn.id = returnEntity.id ?? .init() 44 | newReturn.amount = returnEntity.amount 45 | newReturn.amountUSD = returnEntity.amountUSD 46 | newReturn.currency = returnEntity.currency ?? spending.wrappedCurrency 47 | newReturn.date = returnEntity.date ?? Date() 48 | newReturn.name = returnEntity.name 49 | 50 | spending.addToReturns(newReturn) 51 | 52 | let privateContext = returnEntity.managedObjectContext 53 | 54 | privateContext?.delete(returnEntity) 55 | } 56 | 57 | func deleteReturn(spendingReturn: ReturnEntity) { 58 | context.delete(spendingReturn) 59 | manager.save() 60 | fetchSpendings() 61 | } 62 | 63 | func editReturn( 64 | entity returnEntity: ReturnEntity, 65 | amount: Double, 66 | amountUSD: Double, 67 | currency: String, 68 | date: Date, 69 | name: String 70 | ) { 71 | returnEntity.amount = amount 72 | returnEntity.amountUSD = amountUSD 73 | returnEntity.currency = currency 74 | returnEntity.date = date 75 | returnEntity.name = name 76 | manager.save() 77 | fetchSpendings() 78 | } 79 | 80 | func editRerturnFromSpending( 81 | spending: SpendingEntity, 82 | oldReturn: ReturnEntity, 83 | amount: Double, 84 | amountUSD: Double, 85 | currency: String, 86 | date: Date, 87 | name: String, 88 | performSave: Bool = true 89 | ) { 90 | guard 91 | let description = NSEntityDescription.entity(forEntityName: "ReturnEntity", in: context) 92 | else { 93 | return 94 | } 95 | 96 | let newReturn = ReturnEntity(entity: description, insertInto: context) 97 | 98 | newReturn.id = UUID() 99 | newReturn.amount = amount 100 | newReturn.amountUSD = amountUSD 101 | newReturn.currency = currency 102 | newReturn.date = date 103 | newReturn.name = name 104 | 105 | spending.removeFromReturns(oldReturn) 106 | spending.addToReturns(newReturn) 107 | 108 | if performSave { 109 | manager.save() 110 | fetchSpendings() 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /financecontrol/View/Settings/Views/Category/AddCategoryView.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AddCategoryView.swift 3 | // financecontrol 4 | // 5 | // Created by PinkXaciD on R 5/07/19. 6 | // 7 | 8 | import SwiftUI 9 | 10 | struct AddCategoryView: View { 11 | @EnvironmentObject private var cdm: CoreDataModel 12 | 13 | @Binding var selectedCategory: CategoryEntity? 14 | let insert: Bool 15 | 16 | @State private var name: String = "" 17 | @State private var colorSelectedDescription: String = "" 18 | @State private var triedToSave: Bool = false 19 | 20 | @FocusState private var isFocused: Bool 21 | 22 | @Environment(\.dismiss) private var dismiss 23 | 24 | var body: some View { 25 | List { 26 | nameSection 27 | 28 | colorSection 29 | } 30 | .navigationTitle("New Category") 31 | .toolbar { 32 | // keyboardToolbar 33 | 34 | trailingToolbar 35 | } 36 | .addKeyboardToolbar(showToolbar: isFocused) { 37 | clearFocus() 38 | } 39 | } 40 | 41 | private var nameSection: some View { 42 | Section { 43 | TextField("Enter name", text: $name) 44 | .focused($isFocused) 45 | .onAppear(perform: fieldFocus) 46 | } footer: { 47 | if triedToSave && name.isEmpty { 48 | Text("Required") 49 | .foregroundColor(.red) 50 | } 51 | 52 | if name.count >= 50 { 53 | Text("\(100 - name.count) characters left") 54 | .foregroundColor(name.count > 100 ? .red : .secondary) 55 | } 56 | } 57 | } 58 | 59 | private var colorSection: some View { 60 | Section { 61 | CustomColorSelector(colorSelectedDescription: $colorSelectedDescription) 62 | .padding(.vertical, 10) 63 | } footer: { 64 | if triedToSave && colorSelectedDescription.isEmpty { 65 | Text("Required") 66 | .foregroundColor(.red) 67 | } 68 | } 69 | } 70 | 71 | private var keyboardToolbar: ToolbarItemGroup { 72 | hideKeyboardToolbar { 73 | clearFocus() 74 | } 75 | } 76 | 77 | private var trailingToolbar: ToolbarItem { 78 | ToolbarItem(placement: .topBarTrailing) { 79 | Button("Save") { 80 | if name.isEmpty || colorSelectedDescription.isEmpty || name.count > 100 { 81 | triedToSave = true 82 | HapticManager.shared.notification(.warning) 83 | } else { 84 | if insert { 85 | selectedCategory = cdm.addCategory(name: name, color: colorSelectedDescription) 86 | } else { 87 | _ = cdm.addCategory(name: name, color: colorSelectedDescription) 88 | } 89 | 90 | HapticManager.shared.notification(.success) 91 | dismiss() 92 | } 93 | } 94 | .font(.body.bold()) 95 | .foregroundColor(name.isEmpty || colorSelectedDescription.isEmpty || name.count > 100 ? .secondary.opacity(0.7) : .accentColor) 96 | } 97 | } 98 | 99 | func clearFocus() { 100 | isFocused = false 101 | } 102 | 103 | func fieldFocus() { 104 | isFocused = true 105 | } 106 | } 107 | 108 | //struct NewCategoryView_Previews: PreviewProvider { 109 | // static var previews: some View { 110 | // @State var id = UUID() 111 | // 112 | // AddCategoryView(selectedCategory: $id, insert: false) 113 | // .environmentObject(CoreDataModel()) 114 | // } 115 | //} 116 | -------------------------------------------------------------------------------- /financecontrol/Extensions/TimeZone+Extensions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // TimeZone+Extensions.swift 3 | // Squirrel 4 | // 5 | // Created by PinkXaciD on 2025/01/17. 6 | // 7 | 8 | import Foundation 9 | 10 | extension TimeZone { 11 | enum Format: RawRepresentable, CaseIterable { 12 | case gmt, name, location 13 | 14 | var rawValue: Int { 15 | switch self { 16 | case .gmt: 17 | 0 18 | case .name: 19 | 1 20 | case .location: 21 | 2 22 | } 23 | } 24 | 25 | init(rawValue: Int) { 26 | switch rawValue { 27 | case 0: 28 | self = .gmt 29 | case 1: 30 | self = .name 31 | default: 32 | self = .location 33 | } 34 | } 35 | 36 | var localizedName: String { 37 | switch self { 38 | case .gmt: 39 | String(localized: "timezone-offset-from-gmt", comment: "Timezone ofset from GMT") 40 | case .name: 41 | String(localized: "timezone-name", comment: "Timezone name") 42 | case .location: 43 | String(localized: "timezone-location", comment: "Timezone location") 44 | } 45 | } 46 | 47 | var formatStyle: Date.FormatStyle.Symbol.TimeZone { 48 | switch self { 49 | case .gmt: 50 | .localizedGMT(.short) 51 | case .name: 52 | .specificName(.long) 53 | case .location: 54 | .genericLocation 55 | } 56 | } 57 | } 58 | 59 | func formatted(_ style: Self.Format, for date: Date = Date()) -> String { 60 | var formatStyle = Date.FormatStyle() 61 | formatStyle.timeZone = self 62 | 63 | return date.formatted(formatStyle.timeZone(style.formatStyle)) 64 | } 65 | 66 | func getImage() -> String { 67 | let offset = self.hoursFromGMT() 68 | 69 | switch offset { 70 | case ...(-3): 71 | return "globe.americas.fill" 72 | case ...3: 73 | return "globe.europe.africa.fill" 74 | case ...7: 75 | return "globe.central.south.asia.fill" 76 | default: 77 | return "globe.asia.australia.fill" 78 | } 79 | } 80 | } 81 | 82 | extension TimeZone { 83 | func hoursFromGMT() -> Double { 84 | return Double(self.secondsFromGMT() / 3600) 85 | } 86 | } 87 | 88 | /* 89 | var name: String { 90 | switch self { 91 | case .identifier: 92 | String(localized: "timezone-identifier", comment: "Timezone identifier") 93 | case .gmt: 94 | String(localized: "timezone-offset-from-gmt", comment: "Timezone ofset from GMT") 95 | case .name: 96 | String(localized: "timezone-name", comment: "Timezone name") 97 | } 98 | } 99 | 100 | var example: String { 101 | switch self { 102 | case .identifier: 103 | TimeZone.autoupdatingCurrent.identifier 104 | case .gmt: 105 | Date().formatted(.dateTime.timeZone(.localizedGMT(.short))) 106 | case .name: 107 | TimeZone.autoupdatingCurrent.localizedName(for: .standard, locale: .autoupdatingCurrent) ?? "Error" 108 | } 109 | } 110 | 111 | func formatTimeZone(_ timeZone: TimeZone) -> String { 112 | switch self { 113 | case .identifier: 114 | return timeZone.identifier 115 | case .gmt: 116 | if timeZone.secondsFromGMT() != 0 { 117 | return "GMT\(timeZone.hoursFromGMT().formatted(.number.sign(strategy: .always())))" 118 | } 119 | 120 | return "GMT" 121 | case .name: 122 | return timeZone.localizedName(for: .standard, locale: .autoupdatingCurrent) ?? timeZone.identifier 123 | } 124 | } 125 | */ 126 | --------------------------------------------------------------------------------